diff --git a/README.md b/README.md index 539a6b9..3624498 100644 --- a/README.md +++ b/README.md @@ -179,9 +179,9 @@ Each demo has automated smoke tests that verify the gateway starts and the REST ```bash # Run smoke tests against a running demo (default: http://localhost:8080) -./tests/smoke_test.sh # Sensor diagnostics (21 tests, incl. fault injection) -./tests/smoke_test_turtlebot3.sh # TurtleBot3 (entity discovery) -./tests/smoke_test_moveit.sh # MoveIt pick-and-place (entity discovery) +./tests/smoke_test.sh # Sensor diagnostics (full API coverage + fault injection + beacons) +./tests/smoke_test_turtlebot3.sh # TurtleBot3 (discovery, data, operations, scripts, triggers, logs) +./tests/smoke_test_moveit.sh # MoveIt pick-and-place (discovery, data, operations, scripts, triggers, logs) ``` CI runs all 3 demos in parallel - each job builds the Docker image, starts the container, and runs the smoke tests against it. See [CI workflow](.github/workflows/ci.yml). diff --git a/tests/smoke_lib.sh b/tests/smoke_lib.sh index 30e930d..6b0edc3 100755 --- a/tests/smoke_lib.sh +++ b/tests/smoke_lib.sh @@ -144,12 +144,23 @@ test_entity_discovery() { local entity_type="$1" shift local entity_ids=("$@") + local expected_count=${#entity_ids[@]} local label label="${entity_type^}" section "Entity Discovery - ${label}" if api_get "/${entity_type}"; then + # Verify exact count - catches duplicate/synthetic entities leaking through + local actual_count + actual_count=$(echo "$RESPONSE" | jq '.items | length') + if [ "$actual_count" = "$expected_count" ]; then + pass "${entity_type} count is ${expected_count}" + else + local actual_ids + actual_ids=$(echo "$RESPONSE" | jq -r '[.items[].id] | sort | join(", ")') + fail "${entity_type} count is ${expected_count}" "got ${actual_count}: ${actual_ids}" + fi for entity_id in "${entity_ids[@]}"; do if echo "$RESPONSE" | items_contain_id "$entity_id"; then pass "${entity_type} contains '${entity_id}'" @@ -162,8 +173,190 @@ test_entity_discovery() { fi } -# Print test summary and exit with appropriate code +# Test that procfs introspection data is available for an app +# Usage: assert_procfs_introspection "lidar-sim" +assert_procfs_introspection() { + local app_id="$1" + local endpoint="/apps/${app_id}/x-medkit-procfs" + if api_get "$endpoint"; then + if echo "$RESPONSE" | jq -e '.pid' > /dev/null 2>&1; then + pass "GET ${endpoint} returns procfs data with pid" + else + fail "GET ${endpoint} returns procfs data with pid" "pid field missing" + fi + else + fail "GET ${endpoint} returns 200" "unexpected status code" + fi +} + +# Test that scripts are listed for a component +# Usage: assert_scripts_list "compute-unit" "run-diagnostics" +assert_scripts_list() { + local component_id="$1" + local expected_script="$2" + local endpoint="/components/${component_id}/scripts" + if api_get "$endpoint"; then + if echo "$RESPONSE" | jq -e '.items | length > 0' > /dev/null 2>&1; then + pass "GET ${endpoint} returns non-empty items" + else + fail "GET ${endpoint} returns non-empty items" "items is empty" + fi + if echo "$RESPONSE" | jq -e --arg s "$expected_script" '.items[] | select(.id == $s)' > /dev/null 2>&1; then + pass "scripts contains '${expected_script}'" + else + fail "scripts contains '${expected_script}'" "not found in response" + fi + else + fail "GET ${endpoint} returns 200" "unexpected status code" + fi +} + +# Execute a script and verify it completes +# Usage: assert_script_execution "compute-unit" "run-diagnostics" [max_wait] +assert_script_execution() { + local component_id="$1" + local script_id="$2" + local max_wait="${3:-30}" + local exec_endpoint="/components/${component_id}/scripts/${script_id}/executions" + + # Start execution + local exec_response + exec_response=$(curl -s -w "\n%{http_code}" -X POST "${API_BASE}${exec_endpoint}" \ + -H "Content-Type: application/json" \ + -d '{"execution_type": "now"}' 2>/dev/null) || true + local exec_http + exec_http=$(echo "$exec_response" | tail -1) + local exec_body + exec_body=$(echo "$exec_response" | sed '$d') + + if [ "$exec_http" != "201" ] && [ "$exec_http" != "200" ] && [ "$exec_http" != "202" ]; then + fail "POST ${exec_endpoint} starts execution" "got HTTP ${exec_http}" + return + fi + pass "POST ${exec_endpoint} starts execution" + + local exec_id + exec_id=$(echo "$exec_body" | jq -r '.id') + if [ -z "$exec_id" ] || [ "$exec_id" = "null" ]; then + fail "script execution returns valid id" "id is null or empty" + return + fi + pass "script execution returns valid id" + + # Poll until completed + echo " Waiting for script '${script_id}' to complete (max ${max_wait}s)..." + local elapsed=0 + while [ $elapsed -lt "$max_wait" ]; do + if api_get "${exec_endpoint}/${exec_id}"; then + local status + status=$(echo "$RESPONSE" | jq -r '.status') + case "$status" in + completed) + pass "script '${script_id}' completed successfully" + return + ;; + failed|terminated) + fail "script '${script_id}' completed successfully" "status: ${status}" + return + ;; + esac + fi + sleep 1 + elapsed=$((elapsed + 1)) + done + fail "script '${script_id}' completed successfully" "timed out after ${max_wait}s" +} + +# Test trigger CRUD lifecycle on an entity +# Usage: assert_triggers_crud "apps" "diagnostic-bridge" "/api/v1/apps/diagnostic-bridge/faults" +assert_triggers_crud() { + local entity_type="$1" + local entity_id="$2" + local resource_uri="$3" + local triggers_endpoint="/${entity_type}/${entity_id}/triggers" + + # Create trigger + echo " Creating OnChange trigger on ${entity_type}/${entity_id}..." + local payload + payload=$(jq -n --arg resource "$resource_uri" \ + '{resource: $resource, trigger_condition: {condition_type: "OnChange"}, multishot: true, lifetime: 60}') + local create_response + create_response=$(curl -s -w "\n%{http_code}" -X POST "${API_BASE}${triggers_endpoint}" \ + -H "Content-Type: application/json" \ + -d "$payload" 2>/dev/null) || true + + local create_http + create_http=$(echo "$create_response" | tail -1) + local create_body + create_body=$(echo "$create_response" | sed '$d') + + if [ "$create_http" = "201" ]; then + pass "POST ${triggers_endpoint} returns 201" + else + fail "POST ${triggers_endpoint} returns 201" "got HTTP ${create_http}" + return + fi + + local trigger_id + trigger_id=$(echo "$create_body" | jq -r '.id') + if [ -n "$trigger_id" ] && [ "$trigger_id" != "null" ]; then + pass "trigger response contains valid id" + else + fail "trigger response contains valid id" "id is null or empty" + return + fi + + local trigger_status + trigger_status=$(echo "$create_body" | jq -r '.status') + if [ "$trigger_status" = "active" ]; then + pass "trigger status is 'active'" + else + fail "trigger status is 'active'" "got '${trigger_status}'" + fi + + # List triggers - verify it appears + if api_get "${triggers_endpoint}"; then + if echo "$RESPONSE" | jq -e --arg id "$trigger_id" '.items[] | select(.id == $id)' > /dev/null 2>&1; then + pass "GET ${triggers_endpoint} lists created trigger" + else + fail "GET ${triggers_endpoint} lists created trigger" "trigger ${trigger_id} not found" + fi + else + fail "GET ${triggers_endpoint} returns 200" "unexpected status code" + fi + + # Delete trigger + local delete_status + delete_status=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \ + "${API_BASE}${triggers_endpoint}/${trigger_id}" 2>/dev/null) || true + + if [ "$delete_status" = "204" ]; then + pass "DELETE trigger ${trigger_id} returns 204" + else + fail "DELETE trigger ${trigger_id} returns 204" "got HTTP ${delete_status}" + fi + + # Verify trigger is gone + if api_get "${triggers_endpoint}"; then + if ! echo "$RESPONSE" | jq -e --arg id "$trigger_id" '.items[] | select(.id == $id)' > /dev/null 2>&1; then + pass "trigger no longer listed after deletion" + else + fail "trigger no longer listed after deletion" "still found in list" + fi + else + fail "GET ${triggers_endpoint} returns 200 after delete" "unexpected status code" + fi +} + +# Print test summary (called via EXIT trap - do not call exit here) +SUMMARY_PRINTED=false print_summary() { + # Guard against double-printing when called as both trap and explicit call + if [ "$SUMMARY_PRINTED" = true ]; then + return + fi + SUMMARY_PRINTED=true + echo "" echo -e "${BLUE}================================${NC}" local total=$((PASS_COUNT + FAIL_COUNT)) @@ -172,7 +365,7 @@ print_summary() { if [ "$FAIL_COUNT" -gt 0 ]; then echo -e "\n ${RED}Failed tests:${FAILED_TESTS}${NC}" echo -e "${BLUE}================================${NC}" - exit 1 + return fi echo -e "${BLUE}================================${NC}" diff --git a/tests/smoke_test.sh b/tests/smoke_test.sh index 1c991ce..d827540 100755 --- a/tests/smoke_test.sh +++ b/tests/smoke_test.sh @@ -12,6 +12,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=tests/smoke_lib.sh source "${SCRIPT_DIR}/smoke_lib.sh" +trap print_summary EXIT + # --- Wait for gateway startup --- wait_for_gateway 90 @@ -31,9 +33,18 @@ else fail "GET /health returns 200" "unexpected status code" fi -test_entity_discovery "areas" sensors processing diagnostics -test_entity_discovery "components" lidar-unit imu-unit gps-unit camera-unit -test_entity_discovery "apps" lidar-sim imu-sim gps-sim camera-sim anomaly-detector +test_entity_discovery "areas" sensors processing diagnostics bridge +test_entity_discovery "components" lidar-unit imu-unit gps-unit camera-unit compute-unit gateway fault-manager diagnostic-bridge-unit +test_entity_discovery "apps" lidar-sim imu-sim gps-sim camera-sim anomaly-detector medkit-gateway medkit-fault-manager diagnostic-bridge +test_entity_discovery "functions" sensor-monitoring anomaly-detection fault-management + +section "Discovery Relationships" + +assert_non_empty_items "/areas/sensors/components" + +section "Linux Introspection" + +assert_procfs_introspection "lidar-sim" section "Data Access" @@ -49,6 +60,30 @@ else fail "configurations contains 'noise_stddev' parameter" "not found in response" fi +section "Operations" + +# fault_manager services may take extra time to be discovered via runtime graph introspection +echo " Waiting for fault-manager operations to appear (max 30s)..." +if poll_until "/apps/medkit-fault-manager/operations" '.items | length > 0' 30; then + pass "GET /apps/medkit-fault-manager/operations returns non-empty items" +else + fail "GET /apps/medkit-fault-manager/operations returns non-empty items" "items still empty after 30s" +fi + +section "Scripts" + +assert_scripts_list "compute-unit" "run-diagnostics" +assert_script_execution "compute-unit" "run-diagnostics" 30 + +section "Bulk Data" + +# Bulk data endpoint should return 200 with categories list (may be empty without faults) +if api_get "/apps/diagnostic-bridge/bulk-data"; then + pass "GET /apps/diagnostic-bridge/bulk-data returns 200" +else + fail "GET /apps/diagnostic-bridge/bulk-data returns 200" "unexpected status code" +fi + section "Logs" assert_non_empty_items "/apps/medkit-gateway/logs" @@ -116,67 +151,38 @@ fi section "Triggers" -# Create a trigger on diagnostic-bridge faults -echo " Creating OnChange fault trigger..." -TRIGGER_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${API_BASE}/apps/diagnostic-bridge/triggers" \ - -H "Content-Type: application/json" \ - -d '{"resource":"/api/v1/apps/diagnostic-bridge/faults","trigger_condition":{"condition_type":"OnChange"},"multishot":true,"lifetime":60}' 2>/dev/null) || true - -TRIGGER_HTTP=$(echo "$TRIGGER_RESPONSE" | tail -1) -TRIGGER_BODY=$(echo "$TRIGGER_RESPONSE" | sed '$d') - -if [ "$TRIGGER_HTTP" = "201" ]; then - pass "POST /apps/diagnostic-bridge/triggers returns 201" -else - fail "POST /apps/diagnostic-bridge/triggers returns 201" "got HTTP $TRIGGER_HTTP" -fi - -TRIGGER_ID=$(echo "$TRIGGER_BODY" | jq -r '.id') -if [ -n "$TRIGGER_ID" ] && [ "$TRIGGER_ID" != "null" ]; then - pass "trigger response contains valid id" -else - fail "trigger response contains valid id" "id is null or empty" -fi - -TRIGGER_STATUS=$(echo "$TRIGGER_BODY" | jq -r '.status') -if [ "$TRIGGER_STATUS" = "active" ]; then - pass "trigger status is 'active'" -else - fail "trigger status is 'active'" "got '$TRIGGER_STATUS'" -fi - -# List triggers - verify it appears -if api_get "/apps/diagnostic-bridge/triggers"; then - if echo "$RESPONSE" | jq -e --arg id "$TRIGGER_ID" '.items[] | select(.id == $id)' > /dev/null 2>&1; then - pass "GET /apps/diagnostic-bridge/triggers lists created trigger" - else - fail "GET /apps/diagnostic-bridge/triggers lists created trigger" "trigger $TRIGGER_ID not found" - fi -else - fail "GET /apps/diagnostic-bridge/triggers returns 200" "unexpected status code" -fi - -# Delete trigger -TRIGGER_DELETE_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \ - "${API_BASE}/apps/diagnostic-bridge/triggers/${TRIGGER_ID}" 2>/dev/null) || true - -if [ "$TRIGGER_DELETE_STATUS" = "204" ]; then - pass "DELETE /apps/diagnostic-bridge/triggers/$TRIGGER_ID returns 204" -else - fail "DELETE /apps/diagnostic-bridge/triggers/$TRIGGER_ID returns 204" "got HTTP $TRIGGER_DELETE_STATUS" -fi - -# Verify trigger is gone -if api_get "/apps/diagnostic-bridge/triggers"; then - if ! echo "$RESPONSE" | jq -e --arg id "$TRIGGER_ID" '.items[] | select(.id == $id)' > /dev/null 2>&1; then - pass "trigger no longer listed after deletion" - else - fail "trigger no longer listed after deletion" "still found in list" +assert_triggers_crud "apps" "diagnostic-bridge" "/api/v1/apps/diagnostic-bridge/faults" + +section "Beacon Discovery" + +# Beacon data is exposed at vendor extension endpoints: +# /apps/{id}/x-medkit-topic-beacon (BEACON_MODE=topic) +# /apps/{id}/x-medkit-param-beacon (BEACON_MODE=param) +# When BEACON_MODE=none (CI default), these endpoints return 404. +beacon_found=false +for beacon_type in topic-beacon param-beacon; do + if api_get "/apps/lidar-sim/x-medkit-${beacon_type}"; then + beacon_found=true + pass "GET /apps/lidar-sim/x-medkit-${beacon_type} returns 200" + if echo "$RESPONSE" | jq -e '.status' > /dev/null 2>&1; then + pass "beacon response contains 'status' field" + else + fail "beacon response contains 'status' field" "field missing" + fi + if echo "$RESPONSE" | jq -e '.entity_id' > /dev/null 2>&1; then + pass "beacon response contains 'entity_id' field" + else + fail "beacon response contains 'entity_id' field" "field missing" + fi + break fi -else - fail "GET /apps/diagnostic-bridge/triggers returns 200 after delete" "unexpected status code" +done +if [ "$beacon_found" = false ]; then + # Not a failure - beacons are optional depending on BEACON_MODE + echo -e " ${BLUE}SKIP${NC} beacon not active (BEACON_MODE=none or plugin not loaded)" fi # --- Summary --- -print_summary +# print_summary runs via EXIT trap; exit code reflects test results +[ "$FAIL_COUNT" -eq 0 ] diff --git a/tests/smoke_test_moveit.sh b/tests/smoke_test_moveit.sh index c279dbc..30533e4 100755 --- a/tests/smoke_test_moveit.sh +++ b/tests/smoke_test_moveit.sh @@ -2,7 +2,10 @@ # Smoke tests for moveit_pick_place demo # Runs from the host against the containerized gateway on localhost:8080 # -# Tests: health, entity discovery (areas, components, apps from manifest) +# Tests: health, entity discovery (areas/components/apps/functions), +# discovery relationships, Linux introspection, data access, operations, +# configurations, scripts (list + execution), bulk data, faults, logs, +# trigger CRUD lifecycle # Uses demo.launch.py (fake hardware, no Gazebo) for CI stability # # Usage: ./tests/smoke_test_moveit.sh [GATEWAY_URL] @@ -15,6 +18,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=tests/smoke_lib.sh source "${SCRIPT_DIR}/smoke_lib.sh" +trap print_summary EXIT + # --- Wait for gateway startup --- wait_for_gateway 120 @@ -35,11 +40,64 @@ fi test_entity_discovery "areas" manipulation planning diagnostics bridge test_entity_discovery "components" panda-arm panda-gripper moveit-planning pick-place-loop gateway fault-manager diagnostic-bridge test_entity_discovery "apps" joint-state-broadcaster panda-arm-controller panda-hand-controller robot-state-publisher move-group pick-place-node medkit-gateway medkit-fault-manager diagnostic-bridge-app manipulation-monitor +test_entity_discovery "functions" pick-and-place motion-planning gripper-control fault-management + +section "Discovery Relationships" + +assert_non_empty_items "/areas/manipulation/components" + +section "Linux Introspection" + +assert_procfs_introspection "medkit-gateway" + +section "Data Access" + +assert_non_empty_items "/apps/medkit-gateway/data" + +section "Operations" + +# fault_manager services may take extra time to be discovered via runtime graph introspection +echo " Waiting for fault-manager operations to appear (max 30s)..." +if poll_until "/apps/medkit-fault-manager/operations" '.items | length > 0' 30; then + pass "GET /apps/medkit-fault-manager/operations returns non-empty items" +else + fail "GET /apps/medkit-fault-manager/operations returns non-empty items" "items still empty after 30s" +fi + +section "Configurations" + +assert_non_empty_items "/apps/medkit-gateway/configurations" + +section "Scripts" + +assert_scripts_list "moveit-planning" "arm-self-test" +assert_script_execution "moveit-planning" "arm-self-test" 30 + +section "Bulk Data" + +if api_get "/apps/diagnostic-bridge-app/bulk-data"; then + pass "GET /apps/diagnostic-bridge-app/bulk-data returns 200" +else + fail "GET /apps/diagnostic-bridge-app/bulk-data returns 200" "unexpected status code" +fi + +section "Faults" + +if api_get "/faults"; then + pass "GET /faults returns 200" +else + fail "GET /faults returns 200" "unexpected status code" +fi section "Logs" assert_non_empty_items "/apps/medkit-gateway/logs" +section "Triggers" + +assert_triggers_crud "apps" "diagnostic-bridge-app" "/api/v1/apps/diagnostic-bridge-app/faults" + # --- Summary --- -print_summary +# print_summary runs via EXIT trap; exit code reflects test results +[ "$FAIL_COUNT" -eq 0 ] diff --git a/tests/smoke_test_turtlebot3.sh b/tests/smoke_test_turtlebot3.sh index 1a54ffa..54a1fcd 100755 --- a/tests/smoke_test_turtlebot3.sh +++ b/tests/smoke_test_turtlebot3.sh @@ -2,7 +2,10 @@ # Smoke tests for turtlebot3_integration demo # Runs from the host against the containerized gateway on localhost:8080 # -# Tests: health, entity discovery (areas, components, apps from manifest) +# Tests: health, entity discovery (areas/components/apps/functions), +# discovery relationships, Linux introspection, data access, operations, +# configurations, scripts (list + execution), bulk data, faults, logs, +# trigger CRUD lifecycle # No fault injection - Gazebo-based demo is too complex for reliable CI fault testing # # Usage: ./tests/smoke_test_turtlebot3.sh [GATEWAY_URL] @@ -15,6 +18,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=tests/smoke_lib.sh source "${SCRIPT_DIR}/smoke_lib.sh" +trap print_summary EXIT + # --- Wait for gateway startup --- # Turtlebot3 needs Gazebo + Nav2 - allow extra startup time @@ -35,12 +40,65 @@ fi test_entity_discovery "areas" robot navigation diagnostics bridge test_entity_discovery "components" turtlebot3-base lidar-sensor nav2-stack gateway fault-manager diagnostic-bridge-unit -test_entity_discovery "apps" turtlebot3-node robot-state-publisher amcl bt-navigator controller-server planner-server velocity-smoother medkit-gateway medkit-fault-manager diagnostic-bridge anomaly-detector +test_entity_discovery "apps" turtlebot3-node robot-state-publisher gazebo amcl bt-navigator controller-server planner-server velocity-smoother medkit-gateway medkit-fault-manager diagnostic-bridge anomaly-detector +test_entity_discovery "functions" autonomous-navigation robot-control fault-management + +section "Discovery Relationships" + +assert_non_empty_items "/areas/robot/components" + +section "Linux Introspection" + +assert_procfs_introspection "medkit-gateway" + +section "Data Access" + +assert_non_empty_items "/apps/medkit-gateway/data" + +section "Operations" + +# fault_manager services may take extra time to be discovered in Gazebo-heavy demos +echo " Waiting for fault-manager operations to appear (max 30s)..." +if poll_until "/apps/medkit-fault-manager/operations" '.items | length > 0' 30; then + pass "GET /apps/medkit-fault-manager/operations returns non-empty items" +else + fail "GET /apps/medkit-fault-manager/operations returns non-empty items" "items still empty after 30s" +fi + +section "Configurations" + +assert_non_empty_items "/apps/medkit-gateway/configurations" + +section "Scripts" + +assert_scripts_list "nav2-stack" "nav-health-check" +assert_script_execution "nav2-stack" "nav-health-check" 30 + +section "Bulk Data" + +if api_get "/apps/diagnostic-bridge/bulk-data"; then + pass "GET /apps/diagnostic-bridge/bulk-data returns 200" +else + fail "GET /apps/diagnostic-bridge/bulk-data returns 200" "unexpected status code" +fi + +section "Faults" + +if api_get "/faults"; then + pass "GET /faults returns 200" +else + fail "GET /faults returns 200" "unexpected status code" +fi section "Logs" assert_non_empty_items "/apps/medkit-gateway/logs" +section "Triggers" + +assert_triggers_crud "apps" "diagnostic-bridge" "/api/v1/apps/diagnostic-bridge/faults" + # --- Summary --- -print_summary +# print_summary runs via EXIT trap; exit code reflects test results +[ "$FAIL_COUNT" -eq 0 ]