Skip to content

Commit 802ccb2

Browse files
committed
add curses tests, lint
1 parent 7fec644 commit 802ccb2

File tree

5 files changed

+222
-2
lines changed

5 files changed

+222
-2
lines changed

.github/workflows/debian-packages.yml

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,54 @@ jobs:
128128
129129
# Verify packages are installed
130130
dpkg -s ros-${{ matrix.ros_distro }}-r2s-gw ros-${{ matrix.ros_distro }}-greenwave-monitor ros-${{ matrix.ros_distro }}-greenwave-monitor-interfaces
131+
shell: bash
132+
133+
- name: Test ncurses_dashboard execution (no extra dependencies)
134+
run: |
135+
source /opt/ros/${{ matrix.ros_distro }}/setup.bash
136+
137+
# Test ncurses frontend BEFORE installing any pip dependencies
138+
# This verifies it works with only stdlib (curses) from the debian package
139+
echo "Testing ncurses_dashboard (no extra dependencies required)..."
140+
141+
# Start monitor node in background for ncurses to connect to
142+
ros2 run greenwave_monitor greenwave_monitor > /dev/null 2>&1 & echo $! > /tmp/gwm_ncurses.pid
143+
sleep 3
144+
145+
# Run ncurses frontend with simulated terminal and quit command
146+
# Exit code 0 means clean exit (not 11 for SIGSEGV or 134 for SIGABRT)
147+
set +e
148+
timeout 10s script -qfec 'python3 -m greenwave_monitor.ncurses_frontend' /dev/null <<< $'q'
149+
EXIT_CODE=$?
150+
set -e
151+
152+
if [ $EXIT_CODE -eq 0 ]; then
153+
echo "✓ ncurses_dashboard exited cleanly"
154+
elif [ $EXIT_CODE -eq 124 ]; then
155+
echo "✗ ncurses_dashboard timed out"
156+
kill -9 "$(cat /tmp/gwm_ncurses.pid)" 2>/dev/null || true
157+
exit 1
158+
elif [ $EXIT_CODE -eq 11 ] || [ $EXIT_CODE -eq 139 ]; then
159+
echo "✗ ncurses_dashboard crashed with SIGSEGV (code: $EXIT_CODE)"
160+
kill -9 "$(cat /tmp/gwm_ncurses.pid)" 2>/dev/null || true
161+
exit 1
162+
elif [ $EXIT_CODE -eq 134 ]; then
163+
echo "✗ ncurses_dashboard crashed with SIGABRT/core dump (code: $EXIT_CODE)"
164+
kill -9 "$(cat /tmp/gwm_ncurses.pid)" 2>/dev/null || true
165+
exit 1
166+
else
167+
echo "✓ ncurses_dashboard exited with code: $EXIT_CODE (acceptable)"
168+
fi
131169
132-
# Install Python dependencies
170+
# Cleanup
171+
kill -INT "$(cat /tmp/gwm_ncurses.pid)" 2>/dev/null || true
172+
sleep 1
173+
kill -9 "$(cat /tmp/gwm_ncurses.pid)" 2>/dev/null || true
174+
shell: bash
175+
176+
- name: Install Python dependencies for r2s_gw
177+
run: |
178+
# Install Python dependencies (only needed for r2s_gw, not ncurses)
133179
apt-get install -y python3-pip || true
134180
if [[ "${{ matrix.ros_distro }}" == "jazzy" || \
135181
"${{ matrix.ros_distro }}" == "kilted" || \

greenwave_monitor/CMakeLists.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,12 @@ if(BUILD_TESTING)
118118
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
119119
)
120120

121+
# Add ncurses frontend smoke tests
122+
ament_add_pytest_test(test_ncurses_frontend test/test_ncurses_frontend.py
123+
TIMEOUT 120
124+
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
125+
)
126+
121127
# Add gtests
122128
ament_add_gtest(test_message_diagnostics test/test_message_diagnostics.cpp
123129
TIMEOUT 60

greenwave_monitor/greenwave_monitor/ncurses_frontend.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class GreenwaveNcursesFrontend(Node):
4141
"""Ncurses frontend for Greenwave Monitor."""
4242

4343
def __init__(self):
44+
"""Initialize the ncurses frontend node."""
4445
super().__init__('greenwave_ncurses_frontend')
4546

4647
self.running = True
@@ -126,6 +127,7 @@ def show_status(self, message: str):
126127

127128

128129
def curses_main(stdscr, node):
130+
"""Run the main curses UI loop for displaying topics and diagnostics."""
129131
stdscr.nodelay(True)
130132
curses.curs_set(0)
131133
stdscr.keypad(True)
@@ -447,6 +449,7 @@ def curses_main(stdscr, node):
447449

448450

449451
def main(args=None):
452+
"""Entry point for the ncurses frontend application."""
450453
rclpy.init(args=args)
451454
node = GreenwaveNcursesFrontend()
452455
thread = None

greenwave_monitor/greenwave_monitor/ui_adaptor.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env python3
22

33
# SPDX-FileCopyrightText: NVIDIA CORPORATION & AFFILIATES
4-
# Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
4+
# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
55
#
66
# Licensed under the Apache License, Version 2.0 (the "License");
77
# you may not use this file except in compliance with the License.
@@ -100,6 +100,7 @@ class GreenwaveUiAdaptor:
100100
"""
101101

102102
def __init__(self, node: Node, monitor_node_name: str = 'greenwave_monitor'):
103+
"""Initialize the UI adaptor for subscribing to diagnostics and managing topics."""
103104
self.node = node
104105
self.monitor_node_name = monitor_node_name
105106
self.data_lock = threading.Lock()
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
#!/usr/bin/env python3
2+
3+
# SPDX-FileCopyrightText: NVIDIA CORPORATION & AFFILIATES
4+
# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
# SPDX-License-Identifier: Apache-2.0
19+
20+
"""Smoke tests for ncurses frontend to ensure it starts and exits cleanly."""
21+
22+
import os
23+
import signal
24+
import subprocess
25+
import time
26+
import unittest
27+
28+
from greenwave_monitor.test_utils import (
29+
create_minimal_publisher,
30+
create_monitor_node,
31+
MONITOR_NODE_NAME
32+
)
33+
import launch
34+
import launch_testing
35+
from launch_testing import post_shutdown_test
36+
import launch_testing.actions
37+
from launch_testing.asserts import assertExitCodes
38+
import pytest
39+
40+
41+
@pytest.mark.launch_test
42+
def generate_test_description():
43+
"""Generate launch description for ncurses frontend smoke test."""
44+
# Launch the greenwave_monitor node
45+
ros2_monitor_node = create_monitor_node(
46+
node_name=MONITOR_NODE_NAME,
47+
topics=['/test_topic']
48+
)
49+
50+
# Create a test publisher
51+
test_publisher = create_minimal_publisher('/test_topic', 30.0, 'image')
52+
53+
return (
54+
launch.LaunchDescription([
55+
ros2_monitor_node,
56+
test_publisher,
57+
launch_testing.actions.ReadyToTest()
58+
]), {}
59+
)
60+
61+
62+
class TestNcursesFrontendSmokeTest(unittest.TestCase):
63+
"""Smoke tests for ncurses frontend."""
64+
65+
def test_ncurses_frontend_startup_and_shutdown(self):
66+
"""Test that ncurses frontend starts and exits cleanly without crashing."""
67+
# Set environment to use a fake terminal for ncurses
68+
env = os.environ.copy()
69+
env['TERM'] = 'xterm'
70+
71+
# Start the ncurses frontend in a subprocess
72+
proc = subprocess.Popen(
73+
['python3', '-m', 'greenwave_monitor.ncurses_frontend'],
74+
stdin=subprocess.PIPE,
75+
stdout=subprocess.PIPE,
76+
stderr=subprocess.PIPE,
77+
env=env
78+
)
79+
80+
try:
81+
# Give it time to initialize
82+
time.sleep(2.0)
83+
84+
# Check if process is still running (didn't crash on startup)
85+
poll_result = proc.poll()
86+
self.assertIsNone(poll_result, 'Process should still be running after startup')
87+
88+
# Send 'q' to quit
89+
if proc.stdin:
90+
proc.stdin.write(b'q')
91+
proc.stdin.flush()
92+
93+
# Wait for clean exit with timeout
94+
try:
95+
return_code = proc.wait(timeout=5.0)
96+
# Should exit cleanly with code 0 (not 11 for SIGSEGV or -6 for SIGABRT)
97+
self.assertEqual(
98+
return_code, 0,
99+
f'Process should exit cleanly with code 0, got {return_code}'
100+
)
101+
except subprocess.TimeoutExpired:
102+
self.fail('Process did not exit within timeout after quit command')
103+
104+
finally:
105+
# Ensure cleanup
106+
if proc.poll() is None:
107+
proc.send_signal(signal.SIGTERM)
108+
try:
109+
proc.wait(timeout=2.0)
110+
except subprocess.TimeoutExpired:
111+
proc.kill()
112+
proc.wait()
113+
114+
def test_ncurses_frontend_signal_handling(self):
115+
"""Test that ncurses frontend handles SIGINT and SIGTERM gracefully."""
116+
env = os.environ.copy()
117+
env['TERM'] = 'xterm'
118+
119+
for sig in [signal.SIGINT, signal.SIGTERM]:
120+
with self.subTest(signal=sig):
121+
proc = subprocess.Popen(
122+
['python3', '-m', 'greenwave_monitor.ncurses_frontend'],
123+
stdin=subprocess.PIPE,
124+
stdout=subprocess.PIPE,
125+
stderr=subprocess.PIPE,
126+
env=env
127+
)
128+
129+
try:
130+
# Give it time to initialize
131+
time.sleep(2.0)
132+
133+
# Send signal
134+
proc.send_signal(sig)
135+
136+
# Wait for clean exit
137+
try:
138+
return_code = proc.wait(timeout=5.0)
139+
# Should exit cleanly (0 or terminated by signal)
140+
# SIGINT/SIGTERM result in negative return codes on Unix
141+
self.assertIn(
142+
return_code, [0, -sig, 128 + sig],
143+
f'Process should handle {sig} gracefully, got {return_code}'
144+
)
145+
except subprocess.TimeoutExpired:
146+
self.fail(f'Process did not exit within timeout after {sig}')
147+
148+
finally:
149+
if proc.poll() is None:
150+
proc.kill()
151+
proc.wait()
152+
153+
154+
@post_shutdown_test()
155+
class TestNcursesFrontendPostShutdown(unittest.TestCase):
156+
"""Post-shutdown tests."""
157+
158+
def test_monitor_shutdown(self, proc_info):
159+
"""Test that all processes shut down correctly."""
160+
assertExitCodes(proc_info, allowable_exit_codes=[0])
161+
162+
163+
if __name__ == '__main__':
164+
unittest.main()

0 commit comments

Comments
 (0)