From cefb3392b14fc66fbfe351844717536fd4cbc8e0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:44:58 +0000 Subject: [PATCH 1/8] Initial plan From 190a8ce5a8555df602ad6e93ab4aacd4be0416b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:46:42 +0000 Subject: [PATCH 2/8] tests/tap_runner: intercept stderr as TAP diagnostic lines Agent-Logs-Url: https://github.com/igaw/nvme-cli/sessions/e2a4da2b-f717-40d3-b6ac-5da0c88fb18e Co-authored-by: igaw <1050803+igaw@users.noreply.github.com> --- tests/tap_runner.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/tap_runner.py b/tests/tap_runner.py index e04ac9374a..2c8b81f85f 100644 --- a/tests/tap_runner.py +++ b/tests/tap_runner.py @@ -112,19 +112,24 @@ def run_tests(test_module_name: str, start_dir: str | None = None) -> bool: suite = loader.loadTestsFromModule(module) real_stdout = sys.stdout + real_stderr = sys.stderr # 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. + # Redirect stdout and stderr so any print()/sys.stderr.write() 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] + sys.stderr = DiagnosticCapture(real_stdout) # type: ignore[assignment] try: result = TAPTestResult() suite.run(result) finally: sys.stdout.flush() sys.stdout = real_stdout + sys.stderr.flush() + sys.stderr = real_stderr result.print_tap(real_stdout) return result.wasSuccessful() From f1aa9538c4d7ae697f6145cec92de1d280b35edd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:04:02 +0000 Subject: [PATCH 3/8] tests/tap_runner: capture subprocess stderr at fd level as TAP diagnostics Agent-Logs-Url: https://github.com/igaw/nvme-cli/sessions/0ac3f6cb-9e6e-4b6d-8bb6-da68c04596b8 Co-authored-by: igaw <1050803+igaw@users.noreply.github.com> --- tests/tap_runner.py | 51 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/tap_runner.py b/tests/tap_runner.py index 2c8b81f85f..c164e0c0c9 100644 --- a/tests/tap_runner.py +++ b/tests/tap_runner.py @@ -15,7 +15,9 @@ import argparse import importlib import io +import os import sys +import threading import traceback import unittest @@ -42,6 +44,51 @@ def flush(self) -> None: self._real.flush() +class FDCapture: + """Redirect a file descriptor at the OS level and re-emit captured output + as TAP diagnostic lines. This intercepts writes from subprocesses which + bypass the Python-level sys.stderr redirect.""" + + def __init__(self, fd: int, real_stdout: io.TextIOBase) -> None: + self._fd = fd + self._real = real_stdout + self._saved_fd = os.dup(fd) + r_fd, w_fd = os.pipe() + os.dup2(w_fd, fd) + os.close(w_fd) + self._thread = threading.Thread(target=self._reader, args=(r_fd,), + daemon=True) + # daemon=True: if restore() is somehow never called (e.g. os._exit()), + # the process can still exit rather than hang on a blocking read. + self._thread.start() + + def _reader(self, r_fd: int) -> None: + buf = b'' + # Open unbuffered (bufsize=0) so bytes are delivered to the reader + # as soon as they are written, without waiting for a buffer to fill. + with open(r_fd, 'rb', 0) as f: + while True: + chunk = f.read(4096) + if not chunk: + break + buf += chunk + while b'\n' in buf: + line, buf = buf.split(b'\n', 1) + self._real.write( + '# {}\n'.format(line.decode('utf-8', errors='replace'))) + self._real.flush() + if buf: + self._real.write( + '# {}\n'.format(buf.decode('utf-8', errors='replace'))) + self._real.flush() + + def restore(self) -> None: + """Restore the original file descriptor and wait for the reader to drain.""" + os.dup2(self._saved_fd, self._fd) + os.close(self._saved_fd) + self._thread.join() + + class TAPTestResult(unittest.TestResult): """Collect unittest results and render them as TAP version 13.""" @@ -122,6 +169,9 @@ def run_tests(test_module_name: str, start_dir: str | None = None) -> bool: # break the TAP stream. sys.stdout = DiagnosticCapture(real_stdout) # type: ignore[assignment] sys.stderr = DiagnosticCapture(real_stdout) # type: ignore[assignment] + # Also redirect fd 2 at the OS level so that subprocess stderr (which + # inherits the raw file descriptor and bypasses sys.stderr) is captured. + stderr_fd_capture = FDCapture(2, real_stdout) try: result = TAPTestResult() suite.run(result) @@ -130,6 +180,7 @@ def run_tests(test_module_name: str, start_dir: str | None = None) -> bool: sys.stdout = real_stdout sys.stderr.flush() sys.stderr = real_stderr + stderr_fd_capture.restore() result.print_tap(real_stdout) return result.wasSuccessful() From e53d6848f71d2c78c30ca4c4d6fac82efe9bfdc6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:23:49 +0000 Subject: [PATCH 4/8] tests/tap_runner: fix TAP output ordering Agent-Logs-Url: https://github.com/igaw/nvme-cli/sessions/35c2a08f-97fa-4624-b430-e9757f6831ea Co-authored-by: igaw <1050803+igaw@users.noreply.github.com> --- tests/tap_runner.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/tests/tap_runner.py b/tests/tap_runner.py index c164e0c0c9..99f2b5827c 100644 --- a/tests/tap_runner.py +++ b/tests/tap_runner.py @@ -92,10 +92,10 @@ def restore(self) -> None: class TAPTestResult(unittest.TestResult): """Collect unittest results and render them as TAP version 13.""" - def __init__(self) -> None: + def __init__(self, stream: io.TextIOBase) -> None: super().__init__() + self._stream = stream self._test_count = 0 - self._lines: list[str] = [] def _description(self, test: unittest.TestCase) -> str: return '{} ({})'.format(test._testMethodName, type(test).__name__) @@ -103,50 +103,50 @@ def _description(self, test: unittest.TestCase) -> str: def addSuccess(self, test: unittest.TestCase) -> None: super().addSuccess(test) self._test_count += 1 - self._lines.append('ok {} - {}\n'.format( + self._stream.write('ok {} - {}\n'.format( self._test_count, self._description(test))) + self._stream.flush() 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._stream.write('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)) + self._stream.write('# {}\n'.format(subline)) + self._stream.flush() 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._stream.write('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)) + self._stream.write('# {}\n'.format(subline)) + self._stream.flush() 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._stream.write('ok {} - {} # SKIP {}\n'.format( self._test_count, self._description(test), reason)) + self._stream.flush() 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._stream.write('ok {} - {} # TODO expected failure\n'.format( self._test_count, self._description(test))) + self._stream.flush() 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._stream.write('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() + self._stream.flush() def run_tests(test_module_name: str, start_dir: str | None = None) -> bool: @@ -160,8 +160,9 @@ def run_tests(test_module_name: str, start_dir: str | None = None) -> bool: real_stdout = sys.stdout real_stderr = sys.stderr - # TAP version header must be the very first line on stdout. + # TAP version header and plan must appear before any test output. real_stdout.write('TAP version 13\n') + real_stdout.write('1..{}\n'.format(suite.countTestCases())) real_stdout.flush() # Redirect stdout and stderr so any print()/sys.stderr.write() calls from @@ -173,7 +174,7 @@ def run_tests(test_module_name: str, start_dir: str | None = None) -> bool: # inherits the raw file descriptor and bypasses sys.stderr) is captured. stderr_fd_capture = FDCapture(2, real_stdout) try: - result = TAPTestResult() + result = TAPTestResult(real_stdout) suite.run(result) finally: sys.stdout.flush() @@ -182,7 +183,6 @@ def run_tests(test_module_name: str, start_dir: str | None = None) -> bool: sys.stderr = real_stderr stderr_fd_capture.restore() - result.print_tap(real_stdout) return result.wasSuccessful() From 370a1945728d7008c5c509008e39d9607e99c53a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 07:04:27 +0000 Subject: [PATCH 5/8] tests: fix config_file path to be CWD-independent Agent-Logs-Url: https://github.com/igaw/nvme-cli/sessions/96feac4c-99d6-439d-934f-2a3c8af78195 Co-authored-by: igaw <1050803+igaw@users.noreply.github.com> --- tests/nvme_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/nvme_test.py b/tests/nvme_test.py index b34d720243..dc72aaf7ef 100644 --- a/tests/nvme_test.py +++ b/tests/nvme_test.py @@ -77,7 +77,7 @@ def setUp(self): self.do_validate_pci_device = True self.default_nsid = 0x1 self.flbas = 0 - self.config_file = 'tests/config.json' + self.config_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json') self.load_config() if self.do_validate_pci_device: From e4eaf26f1779976425cee4795708bb3fa57f0fa5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 07:18:32 +0000 Subject: [PATCH 6/8] tests: make config.json lookup walk up directory tree Agent-Logs-Url: https://github.com/igaw/nvme-cli/sessions/aa08b21e-e8ef-481b-8625-bdda1970cdbe Co-authored-by: igaw <1050803+igaw@users.noreply.github.com> --- tests/nvme_test.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/nvme_test.py b/tests/nvme_test.py index dc72aaf7ef..2e0095ea1c 100644 --- a/tests/nvme_test.py +++ b/tests/nvme_test.py @@ -36,6 +36,31 @@ from nvme_test_logger import TestNVMeLogger +def find_config_file(filename='config.json'): + """ Search for a configuration file by walking up the directory tree. + + Starting from the directory containing this module, walk toward the + filesystem root until a file with the given name is found. + - Args: + - filename: name of the config file to search for + - Returns: + - Absolute path to the config file + - Raises: + - FileNotFoundError if the file is not found in any ancestor directory + """ + directory = os.path.dirname(os.path.abspath(__file__)) + while True: + candidate = os.path.join(directory, filename) + if os.path.isfile(candidate): + return candidate + parent = os.path.dirname(directory) + if parent == directory: + raise FileNotFoundError( + f"Config file '{filename}' not found in '{os.path.dirname(os.path.abspath(__file__))}' " + f"or any of its parent directories") + directory = parent + + def to_decimal(value): """ Wrapper for converting numbers to base 10 decimal - Args: @@ -77,7 +102,7 @@ def setUp(self): self.do_validate_pci_device = True self.default_nsid = 0x1 self.flbas = 0 - self.config_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json') + self.config_file = find_config_file() self.load_config() if self.do_validate_pci_device: From aaaa9b7524de2774183b435f2715f8f1fb0c41a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 07:29:35 +0000 Subject: [PATCH 7/8] tests: fix tap_runner to route diagnostic output to stderr Agent-Logs-Url: https://github.com/igaw/nvme-cli/sessions/7ae055d8-4c72-40a1-bd86-f23660b6c0bb Co-authored-by: igaw <1050803+igaw@users.noreply.github.com> --- tests/nvme_test.py | 27 +----------- tests/tap_runner.py | 104 +++++++------------------------------------- 2 files changed, 16 insertions(+), 115 deletions(-) diff --git a/tests/nvme_test.py b/tests/nvme_test.py index 2e0095ea1c..dc72aaf7ef 100644 --- a/tests/nvme_test.py +++ b/tests/nvme_test.py @@ -36,31 +36,6 @@ from nvme_test_logger import TestNVMeLogger -def find_config_file(filename='config.json'): - """ Search for a configuration file by walking up the directory tree. - - Starting from the directory containing this module, walk toward the - filesystem root until a file with the given name is found. - - Args: - - filename: name of the config file to search for - - Returns: - - Absolute path to the config file - - Raises: - - FileNotFoundError if the file is not found in any ancestor directory - """ - directory = os.path.dirname(os.path.abspath(__file__)) - while True: - candidate = os.path.join(directory, filename) - if os.path.isfile(candidate): - return candidate - parent = os.path.dirname(directory) - if parent == directory: - raise FileNotFoundError( - f"Config file '{filename}' not found in '{os.path.dirname(os.path.abspath(__file__))}' " - f"or any of its parent directories") - directory = parent - - def to_decimal(value): """ Wrapper for converting numbers to base 10 decimal - Args: @@ -102,7 +77,7 @@ def setUp(self): self.do_validate_pci_device = True self.default_nsid = 0x1 self.flbas = 0 - self.config_file = find_config_file() + self.config_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json') self.load_config() if self.do_validate_pci_device: diff --git a/tests/tap_runner.py b/tests/tap_runner.py index 99f2b5827c..e860bab1b0 100644 --- a/tests/tap_runner.py +++ b/tests/tap_runner.py @@ -15,86 +15,18 @@ import argparse import importlib import io -import os import sys -import threading 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 FDCapture: - """Redirect a file descriptor at the OS level and re-emit captured output - as TAP diagnostic lines. This intercepts writes from subprocesses which - bypass the Python-level sys.stderr redirect.""" - - def __init__(self, fd: int, real_stdout: io.TextIOBase) -> None: - self._fd = fd - self._real = real_stdout - self._saved_fd = os.dup(fd) - r_fd, w_fd = os.pipe() - os.dup2(w_fd, fd) - os.close(w_fd) - self._thread = threading.Thread(target=self._reader, args=(r_fd,), - daemon=True) - # daemon=True: if restore() is somehow never called (e.g. os._exit()), - # the process can still exit rather than hang on a blocking read. - self._thread.start() - - def _reader(self, r_fd: int) -> None: - buf = b'' - # Open unbuffered (bufsize=0) so bytes are delivered to the reader - # as soon as they are written, without waiting for a buffer to fill. - with open(r_fd, 'rb', 0) as f: - while True: - chunk = f.read(4096) - if not chunk: - break - buf += chunk - while b'\n' in buf: - line, buf = buf.split(b'\n', 1) - self._real.write( - '# {}\n'.format(line.decode('utf-8', errors='replace'))) - self._real.flush() - if buf: - self._real.write( - '# {}\n'.format(buf.decode('utf-8', errors='replace'))) - self._real.flush() - - def restore(self) -> None: - """Restore the original file descriptor and wait for the reader to drain.""" - os.dup2(self._saved_fd, self._fd) - os.close(self._saved_fd) - self._thread.join() - - class TAPTestResult(unittest.TestResult): """Collect unittest results and render them as TAP version 13.""" - def __init__(self, stream: io.TextIOBase) -> None: + def __init__(self, stream: io.TextIOBase, diag_stream: io.TextIOBase) -> None: super().__init__() self._stream = stream + self._diag_stream = diag_stream self._test_count = 0 def _description(self, test: unittest.TestCase) -> str: @@ -112,20 +44,20 @@ def addError(self, test: unittest.TestCase, err: object) -> None: self._test_count += 1 self._stream.write('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._stream.write('# {}\n'.format(subline)) self._stream.flush() + self._diag_stream.write( + ''.join(traceback.format_exception(*err))) # type: ignore[misc] + self._diag_stream.flush() def addFailure(self, test: unittest.TestCase, err: object) -> None: super().addFailure(test, err) self._test_count += 1 self._stream.write('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._stream.write('# {}\n'.format(subline)) self._stream.flush() + self._diag_stream.write( + ''.join(traceback.format_exception(*err))) # type: ignore[misc] + self._diag_stream.flush() def addSkip(self, test: unittest.TestCase, reason: str) -> None: super().addSkip(test, reason) @@ -165,23 +97,17 @@ def run_tests(test_module_name: str, start_dir: str | None = None) -> bool: real_stdout.write('1..{}\n'.format(suite.countTestCases())) real_stdout.flush() - # Redirect stdout and stderr so any print()/sys.stderr.write() 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] - sys.stderr = DiagnosticCapture(real_stdout) # type: ignore[assignment] - # Also redirect fd 2 at the OS level so that subprocess stderr (which - # inherits the raw file descriptor and bypasses sys.stderr) is captured. - stderr_fd_capture = FDCapture(2, real_stdout) + # Redirect sys.stdout to real_stderr so that print()/sys.stdout.write() + # calls from setUp/tearDown/tests do not pollute the TAP stream on stdout. + # All diagnostic output (print statements, subprocess stderr via fd 2, and + # test failure tracebacks) goes to stderr so that 'meson test -v' shows it + # live on the terminal. + sys.stdout = real_stderr # type: ignore[assignment] try: - result = TAPTestResult(real_stdout) + result = TAPTestResult(real_stdout, real_stderr) suite.run(result) finally: - sys.stdout.flush() sys.stdout = real_stdout - sys.stderr.flush() - sys.stderr = real_stderr - stderr_fd_capture.restore() return result.wasSuccessful() From 5fc87e4be12008a52a35ff58caa2e37780aef9bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 07:42:23 +0000 Subject: [PATCH 8/8] tests: route print() output to stdout as TAP diagnostic lines Instead of redirecting sys.stdout to real_stderr (which mixes logging with error output), introduce TAPDiagnosticStream that prefixes every print() line with '# ' and writes it to real_stdout. This keeps diagnostic logging on stdout as TAP-compliant diagnostic lines, while error/failure tracebacks continue to go to stderr. Resulting output: stdout: TAP version 13 / plan / # diagnostics / ok|not ok lines stderr: error tracebacks only Agent-Logs-Url: https://github.com/igaw/nvme-cli/sessions/bfd8b019-7cb6-45ff-bf10-301d962076e4 Co-authored-by: igaw <1050803+igaw@users.noreply.github.com> --- tests/tap_runner.py | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/tests/tap_runner.py b/tests/tap_runner.py index e860bab1b0..28a038809c 100644 --- a/tests/tap_runner.py +++ b/tests/tap_runner.py @@ -20,6 +20,34 @@ import unittest +class TAPDiagnosticStream(io.TextIOBase): + """Wrap a stream and prefix every line with '# ' for TAP diagnostics. + + This lets print()/sys.stdout.write() calls from setUp/tearDown/tests + appear on stdout as TAP-compliant diagnostic lines instead of being + mixed into stderr. + """ + + def __init__(self, stream: io.TextIOBase) -> None: + super().__init__() + self._stream = stream + self._pending = '' + + def write(self, s: str) -> int: + self._pending += s + while '\n' in self._pending: + line, self._pending = self._pending.split('\n', 1) + self._stream.write('# {}\n'.format(line)) + self._stream.flush() + return len(s) + + def flush(self) -> None: + if self._pending: + self._stream.write('# {}\n'.format(self._pending)) + self._pending = '' + self._stream.flush() + + class TAPTestResult(unittest.TestResult): """Collect unittest results and render them as TAP version 13.""" @@ -97,12 +125,11 @@ def run_tests(test_module_name: str, start_dir: str | None = None) -> bool: real_stdout.write('1..{}\n'.format(suite.countTestCases())) real_stdout.flush() - # Redirect sys.stdout to real_stderr so that print()/sys.stdout.write() - # calls from setUp/tearDown/tests do not pollute the TAP stream on stdout. - # All diagnostic output (print statements, subprocess stderr via fd 2, and - # test failure tracebacks) goes to stderr so that 'meson test -v' shows it - # live on the terminal. - sys.stdout = real_stderr # type: ignore[assignment] + # Redirect sys.stdout to a TAP diagnostic stream so that + # print()/sys.stdout.write() calls from setUp/tearDown/tests appear on + # stdout as '# ...' diagnostic lines rather than being sent to stderr. + # Error tracebacks (genuine failures) still go to stderr via diag_stream. + sys.stdout = TAPDiagnosticStream(real_stdout) # type: ignore[assignment] try: result = TAPTestResult(real_stdout, real_stderr) suite.run(result)