diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..74cf747 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +.venv +__pycache__ +*.pyc +*.xml +*.xlsx +*.html +*.zip +OpenDDS-durable-data-dir +zipped_executables +archive_reports diff --git a/.github/workflows/.github/1_run_interoperability_tests_local.yml b/.github/workflows/.github/1_run_interoperability_tests_local.yml new file mode 100644 index 0000000..35ac3aa --- /dev/null +++ b/.github/workflows/.github/1_run_interoperability_tests_local.yml @@ -0,0 +1,123 @@ +name: 1 - Run Interoperability Tests Local +run-name: Run Interoperability Tests Local +on: + workflow_dispatch: + inputs: + publishers: + description: Publishers to use + type: string + default: '["connext_dds","dust_dds","eprosima_fastdds","intercom_dds","opendds","toc_coredx_dds", "hdds", "eclipse_cyclone", "zzdds"]' + subscribers: + description: Subscribers to use + type: string + default: '["connext_dds","dust_dds","eprosima_fastdds","intercom_dds","opendds","toc_coredx_dds", "hdds", "eclipse_cyclone", "zzdds"]' +jobs: + generate_timestamp: + runs-on: self-hosted + steps: + - name: Generate timestamp file + run: date '+%Y-%m-%d-%H_%M_%S' > timestamp + - name: Upload timestamp file + uses: actions/upload-artifact@v7 + with: + name: timestamp + path: | + ./timestamp + + run_tests: + runs-on: self-hosted + needs: generate_timestamp + strategy: + fail-fast: false + matrix: + publisher: ${{ fromJson(github.event.inputs.publishers) }} + subscriber: ${{ fromJson(github.event.inputs.subscribers) }} + + steps: + - name: Checkout sources + uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.11.4' + - name: Download shape_main executables + uses: robinraju/release-downloader@v1.13 + with: + repository: "omg-dds/dds-rtps" # <-- PŘIDEJTE TENTO ŘÁDEK + latest: true + fileName: "*" + out-file-path: zipped_executables + preRelease: false + - name: Unzip executables + run: unzip 'zipped_executables/*.zip' -d executables + - name: Install Python requirements + run: pip install --requirement requirements.txt + - name: Run Interoperability script + timeout-minutes: 120 + continue-on-error: true + run: | + publisher_exe=executables/${{ matrix.publisher }}*shape_main_linux + subscriber_exe=executables/${{ matrix.subscriber }}*shape_main_linux + output_file=junit_report-${{ matrix.publisher }}-${{ matrix.subscriber }}.xml + extra_args="" + # Check if publisher executable exists + if ! ls $publisher_exe 1> /dev/null 2>&1; then + echo "ERROR: Publisher executable not found: $publisher_exe" + exit 1 + fi + # Check if subscriber executable exists + if ! ls $subscriber_exe 1> /dev/null 2>&1; then + echo "ERROR: Subscriber executable not found: $subscriber_exe" + exit 1 + fi + if [[ "${subscriber_exe,,}" == *opendds* && "${publisher_exe,,}" == *connext_dds* ]]; then + extra_args="--periodic-announcement 5000" + fi + python3 interoperability_report.py --publisher $publisher_exe --subscriber $subscriber_exe --output-name $output_file $extra_args + - name: Download timestamp + uses: actions/download-artifact@v8 + with: + name: timestamp + - name: Upload report + uses: actions/upload-artifact@v7 + with: + name: junit_report-${{ matrix.publisher }}-${{ matrix.subscriber }} + path: | + ./junit_report-${{ matrix.publisher }}-${{ matrix.subscriber }}.xml + ./timestamp + + generate_report: + runs-on: self-hosted + needs: run_tests + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Download artifacts + uses: actions/download-artifact@v8 + with: + pattern: junit_report-* + merge-multiple: true + - uses: actions/setup-python@v6 + with: + python-version: '3.11.4' + - name: Install Python requirements + run: pip install --requirement requirements.txt + - name: merge reports + run: junitparser merge *.xml junit_interoperability_report.xml + - name: Generate xlsx report + run: python3 generate_xlsx_report.py --input junit_interoperability_report.xml --output interoperability_report.xlsx + - name: XUnit + run: npx xunit-viewer --results=./junit_interoperability_report.xml --output=./index.html + - name: Download timestamp + uses: actions/download-artifact@v8 + with: + name: timestamp + - name: Attach the report + if: always() + uses: actions/upload-artifact@v7 + with: + name: interoperability_report_complete + path: | + ./index.html + ./junit_interoperability_report.xml + ./interoperability_report.xlsx + ./timestamp diff --git a/.github/workflows/.github/workflows/batch_run_and_compare.yml b/.github/workflows/.github/workflows/batch_run_and_compare.yml new file mode 100644 index 0000000..637b607 --- /dev/null +++ b/.github/workflows/.github/workflows/batch_run_and_compare.yml @@ -0,0 +1,129 @@ +name: 3 - Batch Run and Compare Reports +run-name: Batch Run (${{ github.event.inputs.iterations }}x) & Compare Reports +on: + workflow_dispatch: + inputs: + iterations: + description: 'Number of repetitions (N)' + required: true + default: '3' + type: number + publishers: + description: 'Publishers to use' + type: string + default: '["connext_dds","dust_dds","eprosima_fastdds","intercom_dds","opendds","toc_coredx_dds", "hdds", "eclipse_cyclone", "zzdds"]' + subscribers: + description: 'Subscribers to use' + type: string + default: '["connext_dds","dust_dds","eprosima_fastdds","intercom_dds","opendds","toc_coredx_dds", "hdds", "eclipse_cyclone", "zzdds"]' + +permissions: + actions: write + contents: read + +jobs: + run_batches: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Run N iterations of Workflow 1 and Workflow 2 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + ITERATIONS: ${{ github.event.inputs.iterations }} + PUBLISHERS: ${{ github.event.inputs.publishers }} + SUBSCRIBERS: ${{ github.event.inputs.subscribers }} + run: | + echo "Running $ITERATIONS ..." + + for i in $(seq 1 $ITERATIONS); do + echo "==========================================" + echo " Running ITERACI $i of $ITERATIONS" + echo "==========================================" + + # 1. Run Workflow 1 + echo "Running 1 - Run Interoperability Tests..." + gh workflow run "1 - Run Interoperability Tests" \ + --repo "$REPO" \ + -f publishers="$PUBLISHERS" \ + -f subscribers="$SUBSCRIBERS" + + sleep 10 + + # Get ID of last run Workflow 1 + RUN_1_ID=$(gh run list --workflow="1 - Run Interoperability Tests" --repo "$REPO" --limit 1 --json databaseId -q '.[0].databaseId') + echo "Waiting for Workflow 1 (Run ID: $RUN_1_ID)..." + gh run watch "$RUN_1_ID" --repo "$REPO" --exit-status + + # 2. Run Workflow 2 + echo "Running 2 - Upload Test Results to Drive..." + gh workflow run "2 - Upload Test Results to Drive" --repo "$REPO" + + sleep 10 + + # Get ID Workflow 2 + RUN_2_ID=$(gh run list --workflow="2 - Upload Test Results to Drive" --repo "$REPO" --limit 1 --json databaseId -q '.[0].databaseId') + echo "Waiting for Workflow 2 (Run ID: $RUN_2_ID)..." + gh run watch "$RUN_2_ID" --repo "$REPO" --exit-status + + echo "✓ Iteration $i successfully ended." + done + + - name: Setup Rclone + uses: AnimMouse/setup-rclone@v1 + with: + rclone_config: | + [mygdrive] + type = drive + token = ${{ secrets.RCLONE_TOKEN }} + disable_base64: true + + - name: Download zip reports from Google Drive + run: | + mkdir -p gdrive_download/zips + mkdir -p xml_reports + + echo "Downloading report folder from Google Drive..." + rclone copy mygdrive: gdrive_download/ --drive-root-folder-id ${{ secrets.DRIVE_FOLDER_ID }} --include "report_*/**.zip" -v + + echo "Extracting junit XML files ..." + find gdrive_download -type f -name "*.zip" -exec unzip -o {} -d xml_reports/ \; + + echo "List of XML files:" + ls -la xml_reports/ + + - name: Run comparison script + run: | + # 1. download compare script + curl -sSL -o compare_junit_reports.py \ + https://raw.githubusercontent.com/martinrichtersupervag/compare_junit_reports/main/compare_junit_reports/test_compare_junit_reports.py + + # 2. run comparison + python3 compare_junit_reports.py --directory ./xml_reports --compare-all --show-difference | tee comparison_output.txt + + - name: Publish Summary to GitHub Actions + if: always() + run: | + if [ -f comparison_output.txt ]; then + echo "## Comparison results (--compare-all --show-difference)" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + cat comparison_output.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + fi + + - name: Upload Comparison Artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: comparison_results + path: | + ./comparison_output.txt + ./xml_reports/*.txt + ./xml_reports/junit_*.xml diff --git a/.github/workflows/1_run_interoperability_tests.yml b/.github/workflows/1_run_interoperability_tests.yml index 173c82b..bb2920c 100644 --- a/.github/workflows/1_run_interoperability_tests.yml +++ b/.github/workflows/1_run_interoperability_tests.yml @@ -11,9 +11,13 @@ on: description: Subscribers to use type: string default: '["connext_dds","dust_dds","eprosima_fastdds","intercom_dds","opendds","toc_coredx_dds", "hdds", "eclipse_cyclone", "zzdds"]' + runner: + description: Runner to use + type: string + default: 'ubuntu-latest' jobs: generate_timestamp: - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner || 'ubuntu-latest' }} steps: - name: Generate timestamp file run: date '+%Y-%m-%d-%H_%M_%S' > timestamp @@ -25,9 +29,10 @@ jobs: ./timestamp run_tests: - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner || 'ubuntu-latest' }} needs: generate_timestamp strategy: + fail-fast: false matrix: publisher: ${{ fromJson(github.event.inputs.publishers) }} subscriber: ${{ fromJson(github.event.inputs.subscribers) }} @@ -36,11 +41,13 @@ jobs: - name: Checkout sources uses: actions/checkout@v6 - uses: actions/setup-python@v6 + if: runner.environment != 'self-hosted' with: python-version: '3.11.4' - name: Download shape_main executables uses: robinraju/release-downloader@v1.13 with: + repository: "omg-dds/dds-rtps" latest: true fileName: "*" out-file-path: zipped_executables @@ -48,6 +55,7 @@ jobs: - name: Unzip executables run: unzip 'zipped_executables/*.zip' -d executables - name: Install Python requirements + if: runner.environment != 'self-hosted' run: pip install --requirement requirements.txt - name: Run Interoperability script timeout-minutes: 120 @@ -70,7 +78,13 @@ jobs: if [[ "${subscriber_exe,,}" == *opendds* && "${publisher_exe,,}" == *connext_dds* ]]; then extra_args="--periodic-announcement 5000" fi - python3 interoperability_report.py --publisher $publisher_exe --subscriber $subscriber_exe --output-name $output_file $extra_args + if [ "$RUNNER_ENVIRONMENT" = "self-hosted" ]; then + echo "Running on self-hosted runner: executing inside isolated Docker container..." + ./run_in_docker.sh python3 interoperability_report.py --publisher $publisher_exe --subscriber $subscriber_exe --output-name $output_file $extra_args + else + echo "Running in GitHub-hosted environment: executing directly..." + python3 interoperability_report.py --publisher $publisher_exe --subscriber $subscriber_exe --output-name $output_file $extra_args + fi - name: Download timestamp uses: actions/download-artifact@v8 with: @@ -84,7 +98,7 @@ jobs: ./timestamp generate_report: - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner || 'ubuntu-latest' }} needs: run_tests steps: - name: Checkout @@ -95,16 +109,21 @@ jobs: pattern: junit_report-* merge-multiple: true - uses: actions/setup-python@v6 + if: runner.environment != 'self-hosted' with: python-version: '3.11.4' - name: Install Python requirements + if: runner.environment != 'self-hosted' run: pip install --requirement requirements.txt - - name: merge reports - run: junitparser merge *.xml junit_interoperability_report.xml - - name: Generate xlsx report - run: python3 generate_xlsx_report.py --input junit_interoperability_report.xml --output interoperability_report.xlsx - - name: XUnit - run: npx xunit-viewer --results=./junit_interoperability_report.xml --output=./index.html + - name: Generate report files + run: | + if [ "$RUNNER_ENVIRONMENT" = "self-hosted" ]; then + ./run_in_docker.sh ./generate_reports.sh + else + python3 -m junitparser merge *.xml junit_interoperability_report.xml + python3 generate_xlsx_report.py --input junit_interoperability_report.xml --output interoperability_report.xlsx + npx xunit-viewer --results=./junit_interoperability_report.xml --output=./index.html + fi - name: Download timestamp uses: actions/download-artifact@v8 with: diff --git a/.github/workflows/2_upload_artifact.yml b/.github/workflows/2_upload_artifact.yml index 0c9318c..791404a 100644 --- a/.github/workflows/2_upload_artifact.yml +++ b/.github/workflows/2_upload_artifact.yml @@ -1,44 +1,101 @@ name: 2 - Upload Test Results to Drive -run-name: ${{ github.actor }} is testing out GitHub Actions 🚀 -on: workflow_dispatch +run-name: ${{ github.actor }} is uploading test results (${{ inputs.runner || 'ubuntu-latest' }}) 🚀 +on: + workflow_dispatch: + inputs: + runner: + description: 'Runner environment' + required: true + default: 'ubuntu-latest' + type: choice + options: + - ubuntu-latest + - self-hosted + jobs: upload_artifact: - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner || 'ubuntu-latest' }} steps: + - name: Check secrets availability + run: | + if [ -z "${{ secrets.RCLONE_TOKEN }}" ] || [ -z "${{ secrets.DRIVE_FOLDER_ID }}" ]; then + echo "::error::Missing RCLONE_TOKEN or DRIVE_FOLDER_ID secrets. Cannot upload to Drive." + exit 1 + fi + - name: Get Artifact uses: dawidd6/action-download-artifact@v6 with: name: interoperability_report_complete workflow: 1_run_interoperability_tests.yml workflow_conclusion: completed + repo: ${{ github.repository }} + + - name: Ensure system dependencies + run: | + if ! command -v zip >/dev/null 2>&1; then + if command -v sudo >/dev/null 2>&1; then + sudo apt-get update -y && sudo apt-get install -y zip || echo "Warning: sudo apt-get install zip failed. Python fallback will be used." + else + echo "Warning: zip command not found and sudo is not available. Python fallback will be used." + fi + fi + - name: Create files to upload run: | - current_date=$(cat timestamp) - mv index.html interoperability_report.html - for file in *; do \ - mv "$file" "${file%.*}_$current_date.${file##*.}"; \ - done - zip interoperability_report_$current_date.zip * - mkdir report_$current_date - mv *.zip report_$current_date/ - mv *.xlsx report_$current_date/ - - name: Upload zip artifact - uses: adityak74/google-drive-upload-git-action@main + rm -rf report_* interoperability_report_*.zip + if [ -f timestamp ]; then + current_date=$(cat timestamp | tr -d '\r\n') + elif ls timestamp*.timestamp 1>/dev/null 2>&1; then + current_date=$(cat timestamp*.timestamp | head -n 1 | tr -d '\r\n') + else + current_date=$(date '+%Y-%m-%d-%H_%M_%S') + fi + echo "$current_date" > timestamp + + [ -f index.html ] && mv index.html interoperability_report.html + + for file in interoperability_report.html junit_interoperability_report.xml interoperability_report.xlsx timestamp; do + if [ -f "$file" ]; then + cp "$file" "${file%.*}_$current_date.${file##*.}" + fi + done + + # Also preserve any discovery reports if present in artifact + for file in discovery_report*.json junit_discovery_report*.xml; do + if [ -f "$file" ]; then + if [[ "$file" != *"$current_date"* ]]; then + cp "$file" "${file%.*}_$current_date.${file##*.}" + fi + fi + done + + if command -v zip >/dev/null 2>&1; then + zip interoperability_report_$current_date.zip *_$current_date.* + else + python3 -m zipfile -c interoperability_report_$current_date.zip *_$current_date.* + fi + + mkdir -p report_$current_date + mv interoperability_report_$current_date.zip report_$current_date/ + if [ -f interoperability_report_$current_date.xlsx ]; then + cp interoperability_report_$current_date.xlsx report_$current_date/ + fi + + - name: Setup Rclone + uses: AnimMouse/setup-rclone@v1 with: - credentials: ${{ secrets.GCP_CREDENTIAL }} - filename: report_*/*.zip - folderId: ${{ secrets.DRIVE_FOLDER_ID }} - overwrite: "true" - mirrorDirectoryStructure: "true" - - name: Upload spreadsheet - uses: adityak74/google-drive-upload-git-action@main - with: - credentials: ${{ secrets.GCP_CREDENTIAL }} - filename: report_*/*.xlsx - folderId: ${{ secrets.DRIVE_FOLDER_ID }} - overwrite: "true" - mirrorDirectoryStructure: "true" - mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + rclone_config: | + [mygdrive] + type = drive + token = ${{ secrets.RCLONE_TOKEN }} + disable_base64: true + + - name: Upload reports to Google Drive + run: | + current_date=$(cat timestamp) + rclone copy report_$current_date mygdrive:report_$current_date --drive-root-folder-id ${{ secrets.DRIVE_FOLDER_ID }} -v + - name: Attach detailed report html uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/batch_run_and_compare.yml b/.github/workflows/batch_run_and_compare.yml new file mode 100644 index 0000000..637b607 --- /dev/null +++ b/.github/workflows/batch_run_and_compare.yml @@ -0,0 +1,129 @@ +name: 3 - Batch Run and Compare Reports +run-name: Batch Run (${{ github.event.inputs.iterations }}x) & Compare Reports +on: + workflow_dispatch: + inputs: + iterations: + description: 'Number of repetitions (N)' + required: true + default: '3' + type: number + publishers: + description: 'Publishers to use' + type: string + default: '["connext_dds","dust_dds","eprosima_fastdds","intercom_dds","opendds","toc_coredx_dds", "hdds", "eclipse_cyclone", "zzdds"]' + subscribers: + description: 'Subscribers to use' + type: string + default: '["connext_dds","dust_dds","eprosima_fastdds","intercom_dds","opendds","toc_coredx_dds", "hdds", "eclipse_cyclone", "zzdds"]' + +permissions: + actions: write + contents: read + +jobs: + run_batches: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Run N iterations of Workflow 1 and Workflow 2 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + ITERATIONS: ${{ github.event.inputs.iterations }} + PUBLISHERS: ${{ github.event.inputs.publishers }} + SUBSCRIBERS: ${{ github.event.inputs.subscribers }} + run: | + echo "Running $ITERATIONS ..." + + for i in $(seq 1 $ITERATIONS); do + echo "==========================================" + echo " Running ITERACI $i of $ITERATIONS" + echo "==========================================" + + # 1. Run Workflow 1 + echo "Running 1 - Run Interoperability Tests..." + gh workflow run "1 - Run Interoperability Tests" \ + --repo "$REPO" \ + -f publishers="$PUBLISHERS" \ + -f subscribers="$SUBSCRIBERS" + + sleep 10 + + # Get ID of last run Workflow 1 + RUN_1_ID=$(gh run list --workflow="1 - Run Interoperability Tests" --repo "$REPO" --limit 1 --json databaseId -q '.[0].databaseId') + echo "Waiting for Workflow 1 (Run ID: $RUN_1_ID)..." + gh run watch "$RUN_1_ID" --repo "$REPO" --exit-status + + # 2. Run Workflow 2 + echo "Running 2 - Upload Test Results to Drive..." + gh workflow run "2 - Upload Test Results to Drive" --repo "$REPO" + + sleep 10 + + # Get ID Workflow 2 + RUN_2_ID=$(gh run list --workflow="2 - Upload Test Results to Drive" --repo "$REPO" --limit 1 --json databaseId -q '.[0].databaseId') + echo "Waiting for Workflow 2 (Run ID: $RUN_2_ID)..." + gh run watch "$RUN_2_ID" --repo "$REPO" --exit-status + + echo "✓ Iteration $i successfully ended." + done + + - name: Setup Rclone + uses: AnimMouse/setup-rclone@v1 + with: + rclone_config: | + [mygdrive] + type = drive + token = ${{ secrets.RCLONE_TOKEN }} + disable_base64: true + + - name: Download zip reports from Google Drive + run: | + mkdir -p gdrive_download/zips + mkdir -p xml_reports + + echo "Downloading report folder from Google Drive..." + rclone copy mygdrive: gdrive_download/ --drive-root-folder-id ${{ secrets.DRIVE_FOLDER_ID }} --include "report_*/**.zip" -v + + echo "Extracting junit XML files ..." + find gdrive_download -type f -name "*.zip" -exec unzip -o {} -d xml_reports/ \; + + echo "List of XML files:" + ls -la xml_reports/ + + - name: Run comparison script + run: | + # 1. download compare script + curl -sSL -o compare_junit_reports.py \ + https://raw.githubusercontent.com/martinrichtersupervag/compare_junit_reports/main/compare_junit_reports/test_compare_junit_reports.py + + # 2. run comparison + python3 compare_junit_reports.py --directory ./xml_reports --compare-all --show-difference | tee comparison_output.txt + + - name: Publish Summary to GitHub Actions + if: always() + run: | + if [ -f comparison_output.txt ]; then + echo "## Comparison results (--compare-all --show-difference)" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + cat comparison_output.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + fi + + - name: Upload Comparison Artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: comparison_results + path: | + ./comparison_output.txt + ./xml_reports/*.txt + ./xml_reports/junit_*.xml diff --git a/.github/workflows/batch_run_and_compare2.yml b/.github/workflows/batch_run_and_compare2.yml new file mode 100644 index 0000000..e15808e --- /dev/null +++ b/.github/workflows/batch_run_and_compare2.yml @@ -0,0 +1,129 @@ +name: Batch Run and Compare Reports +run-name: Batch Run (${{ github.event.inputs.iterations }}x) & Compare Reports +on: + workflow_dispatch: + inputs: + iterations: + description: 'Number of repetitions (N)' + required: true + default: '3' + type: number + publishers: + description: 'Publishers to use' + type: string + default: '["connext_dds","dust_dds","eprosima_fastdds","intercom_dds","opendds","toc_coredx_dds", "hdds", "eclipse_cyclone", "zzdds"]' + subscribers: + description: 'Subscribers to use' + type: string + default: '["connext_dds","dust_dds","eprosima_fastdds","intercom_dds","opendds","toc_coredx_dds", "hdds", "eclipse_cyclone", "zzdds"]' + +permissions: + actions: write + contents: read + +jobs: + run_batches: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Run N iterations of Workflow 1 and Workflow 2 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + ITERATIONS: ${{ github.event.inputs.iterations }} + PUBLISHERS: ${{ github.event.inputs.publishers }} + SUBSCRIBERS: ${{ github.event.inputs.subscribers }} + run: | + echo "Running $ITERATIONS ..." + + for i in $(seq 1 $ITERATIONS); do + echo "==========================================" + echo " Running ITERACI $i of $ITERATIONS" + echo "==========================================" + + # 1. Run Workflow 1 + echo "Running 1 - Run Interoperability Tests..." + gh workflow run "1 - Run Interoperability Tests" \ + --repo "$REPO" \ + -f publishers="$PUBLISHERS" \ + -f subscribers="$SUBSCRIBERS" + + sleep 10 + + # Get ID of last run Workflow 1 + RUN_1_ID=$(gh run list --workflow="1 - Run Interoperability Tests" --repo "$REPO" --limit 1 --json databaseId -q '.[0].databaseId') + echo "Waiting for Workflow 1 (Run ID: $RUN_1_ID)..." + gh run watch "$RUN_1_ID" --repo "$REPO" --exit-status + + # 2. Run Workflow 2 + echo "Running 2 - Upload Test Results to Drive..." + gh workflow run "2 - Upload Test Results to Drive" --repo "$REPO" + + sleep 10 + + # Get ID Workflow 2 + RUN_2_ID=$(gh run list --workflow="2 - Upload Test Results to Drive" --repo "$REPO" --limit 1 --json databaseId -q '.[0].databaseId') + echo "Waiting for Workflow 2 (Run ID: $RUN_2_ID)..." + gh run watch "$RUN_2_ID" --repo "$REPO" --exit-status + + echo "✓ Iteration $i successfully ended." + done + + - name: Setup Rclone + uses: AnimMouse/setup-rclone@v1 + with: + rclone_config: | + [mygdrive] + type = drive + token = ${{ secrets.RCLONE_TOKEN }} + disable_base64: true + + - name: Download zip reports from Google Drive + run: | + mkdir -p gdrive_download/zips + mkdir -p xml_reports + + echo "Downloading report folder from Google Drive..." + rclone copy mygdrive: gdrive_download/ --drive-root-folder-id ${{ secrets.DRIVE_FOLDER_ID }} --include "report_*/**.zip" -v + + echo "Extracting junit XML files ..." + find gdrive_download -type f -name "*.zip" -exec unzip -o {} -d xml_reports/ \; + + echo "List of XML files:" + ls -la xml_reports/ + + - name: Run comparison script + run: | + # 1. download compare script + curl -sSL -o compare_junit_reports.py \ + https://raw.githubusercontent.com/martinrichtersupervag/compare_junit_reports/main/compare_junit_reports/test_compare_junit_reports.py + + # 2. run comparison + python3 compare_junit_reports.py --directory ./xml_reports --compare-all --show-difference | tee comparison_output.txt + + - name: Publish Summary to GitHub Actions + if: always() + run: | + if [ -f comparison_output.txt ]; then + echo "## Comparison results (--compare-all --show-difference)" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + cat comparison_output.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + fi + + - name: Upload Comparison Artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: comparison_results + path: | + ./comparison_output.txt + ./xml_reports/*.txt + ./xml_reports/junit_*.xml diff --git a/.gitignore b/.gitignore index 4897f53..8fd4429 100644 --- a/.gitignore +++ b/.gitignore @@ -68,11 +68,17 @@ build/ *.xml *.zip *.html +discovery_report_*.json +discovery_summary.json +timestamp +archive_reports/ # Do not ignore templates !doc/_templates/*.html .DS_Store +core +core.* # Generated documentation files gdrive_url.py diff --git a/CLA/CLA_Martin_Richter.md b/CLA/CLA_Martin_Richter.md new file mode 100644 index 0000000..2e3febf --- /dev/null +++ b/CLA/CLA_Martin_Richter.md @@ -0,0 +1,60 @@ +# OMG DDS INTEROPERABILITY REPOSITORY - CONTRIBUTOR LICENSE AGREEMENT + +**This Contributor License Agreement ("Agreement") specifies the terms under which the individual or corporate entity specified in the signature block below (“You”) agree to make intellectual property contributions to the OMG DDS Interoperability Repository. BY SIGNING BELOW YOU ARE AGREEING TO BE BOUND BY THE TERMS OF THIS AGREEMENT. If You are signing this Agreement in Your capacity as an employee, THEN YOUR EMPLOYER AND YOU ARE BOTH BOUND BY THIS AGREEMENT.** + +1. Definitions + + 1. "OMG DDS Interoperability Repository" (or “Repository”) means the Git repository [https://github.com/omg-dds/dds-rtps](https://github.com/omg-dds/dds-rtps). + + 2. "Moderator" means an entity or individual responsible for authorizing changes to the Repository. + + 3. "Submit" (or “Submitted”) means any submission, including source code, binaries, code, pull requests, issue reports, comments, etc., made to the Moderators for inclusion in the Repository either through the Git repository interface or through electronic file transfer. + + 4. A "Contribution" is any original work of authorship, including any modifications or additions to an existing work, that You Submit to the DDS Interoperability Repository. + + 5. A "User" is anyone who accesses the Repository. + +2. Allowable Contribution Representations + + 1. You represent that You have the necessary rights to the Contribution(s) to meet the obligations of this Agreement. If You are employed, Your employer has authorized Contribution(s) under this Agreement. + + 2. You represent that you have no knowledge of third-party intellectual property rights that are likely to be infringed by the Contribution(s). You represent that you have no knowledge that such infringement or any allegation of misappropriation of intellectual property rights is likely to be claimed or has already been claimed. + +3. License + + You grant Moderators a perpetual, worldwide, non-exclusive, assignable, paid-up license to publish, display, and redistribute the Contribution as part of the Repository. You also license to Moderators under the same terms any other intellectual property rights required to publish, display, and redistribute the Contributions as part of the Repository. You further grant all Users of the Repository a license to the Contribution under the terms of the [OMG DDS Interoperability Testing License](../LICENSE.md) included in the Repository. Moderators are under no obligation to publish Contributions. + +4. No Warranty, Consequential Damages. Limited Liability + + Other than explicitly stated herein, You provide the Contribution(s) "as is" with no warranty nor claims of fitness to any purpose. Neither party shall be liable for consequential or special damages of any kind. Other than for breach of warranty or representations herein, the liability of either party to the other shall be limited to $1000. + +5. General + + 1. If You are an agency of the United States Government, then this Agreement will be governed by the United States federal common law. Otherwise, this Agreement will be governed by the laws of the State of California except with regard to its choice of law rules. + + 2. A party may assign this Agreement to an entity acquiring essentially all of the party’s relevant business. + +6. Electronic Signatures + + "Electronic Signature" means any electronic sound, symbol, or process attached to or logically associated with a record and executed and adopted by a party with the intent to sign such record. + + Each party agrees that the Electronic Signatures, whether digital or encrypted, of the parties included in this Agreement are intended to authenticate this writing and to have the same force and effect as manual signatures. + + +IN WITNESS WHEREOF, You, intending to be legally bound, have executed this Agreement or caused Your employer’s proper and duly authorized officer to execute and deliver this Agreement, for good and valuable consideration, the sufficiency of which is hereby acknowledged, as of the day and year first written below. + +**For:** + +Entity Name: _University of Defence, Brno, Czech Republic_ + +Address: _Šumavská 4, Brno, Czech Republic_ + + ("**You**") + +**By:** + +Name: _Martin Richter_ + +Title: _Academic Researcher_ + +Date: _September 21st, 2026_ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3c4c7bf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# Dockerfile for OMG DDS-RTPS Interoperability Testing Suite +# Provides an isolated, reproducible environment (IPv4 only, contained multicast discovery) +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + HOME=/tmp + +# Install core runtime dependencies: Python 3, Node.js (via NodeSource 20 LTS), network tools and tshark +RUN echo "wireshark-common wireshark-common/install-setuid boolean true" | debconf-set-selections \ + && apt-get update && apt-get install -y --no-install-recommends \ + python3 \ + python3-pip \ + ca-certificates \ + curl \ + unzip \ + zip \ + iproute2 \ + procps \ + tshark \ + && chmod 4755 /usr/bin/dumpcap \ + && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && npm install -g xunit-viewer \ + && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /workspace + +# Copy and install python dependencies first to leverage Docker layer caching +COPY requirements.txt /workspace/ +RUN pip3 install --no-cache-dir --break-system-packages -r requirements.txt || \ + pip3 install --no-cache-dir -r requirements.txt + +# Copy all repository files into container +COPY . /workspace + +# Default command: launch test suite +CMD ["/bin/bash", "./run_tests.sh"] diff --git a/INTEROPERABILITY_FIXES.md b/INTEROPERABILITY_FIXES.md new file mode 100644 index 0000000..c5a3e57 --- /dev/null +++ b/INTEROPERABILITY_FIXES.md @@ -0,0 +1,146 @@ +# DDS Interoperability Test Fixes + +## 🔴 Identifikované problémy a řešení + +### 1. **OrderedAccess & CoherentSets - Status kód chyby** + +**Problém:** +- Fast DDS/OpenDDS vrací `SUB_UNSUPPORTED_FEATURE` +- Mělo by vrátit `INCOMPATIBLE_QOS` (QoS se neshodují) + +**Testy ovlivněné:** +- `Test_OrderedAccess_2, 5, 8, 16` +- `Test_CoherentSets_1, 2, 4, 5, 7, 8, 9, 11, 12, 15, 17, 18, 21` + +**Příčina v kódu (shape_main.cxx):** +```cpp +// Řádky 1319-1336 (publisher) a 1534-1582 (subscriber) +#if defined(RTI_CONNEXT_DDS) || defined(TWINOAKS_COREDX) || defined(INTERCOM_DDS) + if (options->ordered_access_enabled) { + pub_qos.presentation.ordered_access = DDS_BOOLEAN_TRUE; + } +#else + if (options->ordered_access_enabled) { + logger.log_message("Presentation Ordered Access = not supported", ERROR); + return false; // ← PROBLÉM: Vrací PUB_UNSUPPORTED_FEATURE + } +#endif +``` + +**Řešení:** +Když DDS implementace nepodporuje Presentation QoS, je to **QoS incompatibility**, ne unsupported feature. +- Publisher by měl pokračovat a čekat na `on_offered_incompatible_qos()` callback +- Subscriber by měl pokračovat a čekat na `on_requested_incompatible_qos()` callback + +--- + +### 2. **TimeBasedFilter - DATA_NOT_CORRECT místo OK** + +**Problém:** +- Test `Test_TimeBasedFilter_0, 1` vrací `DATA_NOT_CORRECT` +- Subscriber dostane jen 2 vzorky místo filtrovaných 50 + +**Příčina:** +```cpp +// shape_main.cxx řádky 1647-1655 +#if defined(EPROSIMA_FAST_DDS) || defined(RTI_CONNEXT_MICRO) + logger.log_message("TimeBasedFilter = not supported", ERROR); + return false; // ← Vrací SUB_UNSUPPORTED_FEATURE +#endif +``` + +Fast DDS to nepodporuje, ale test očekává `OK`. + +**Řešení:** +- Musíme nastavit expected_codes v test_suite.py na `[ReturnCode.OK, ReturnCode.SUB_UNSUPPORTED_FEATURE]` pro Fast DDS +- NEBO: V shape_main.cxx zsilent-fail (nevrátit error, jen loggovat warning) + +--- + +### 3. **Lifespan - DATA_NOT_RECEIVED místo OK** + +**Problém:** +- Testy `Test_Lifespan_0-7` vrací `DATA_NOT_RECEIVED` +- Vzorky expirují předtím, než subscriber stihne číst + +**Příčina:** +```cpp +// shape_main.cxx řádky 1452-1462 +#if defined (RTI_CONNEXT_MICRO) + return false; // ← Micro nepodporuje +#elif defined(EPROSIMA_FAST_DDS) + dw_qos.lifespan FIELD_ACCESSOR.duration = Duration_t(options->lifespan_us * 1e-6); + // ← Jiné kódování Duration_t než ostatní! +#endif +``` + +**Řešení:** +- Ověřit správnost `Duration_t` konverze v Fast DDS + +--- + +## ✅ Doporučená akce + +### **Krok 1: shape_main.cxx - Přestat vracat chyby pro unsupported features** + +Místo `return false;` loggnout warning a pokračovat: + +```cpp +// MÍSTO TOHOTO: +if (options->ordered_access_enabled) { + logger.log_message("Presentation Ordered Access = not supported", ERROR); + return false; +} + +// DĚLEJ TOHLE: +if (options->ordered_access_enabled) { + logger.log_message("WARNING: Presentation Ordered Access not supported - expecting INCOMPATIBLE_QOS", + Verbosity::ERROR); + // Pokračuj - čekal na incompatible_qos callback +} +``` + +### **Krok 2: test_suite.py - Aktualizovat expected_codes pro vendor-specific chování** + +```python +# Pro Fast DDS/OpenDDS testy: +'Test_OrderedAccess_2': { + 'apps': [...], + 'expected_codes': [ReturnCode.INCOMPATIBLE_QOS, ReturnCode.INCOMPATIBLE_QOS], + # Tohle je OK pro všechny vendors - jejich implementace QoS negotiation +} + +# Pokud vendor nepodporuje feature, měl by to signalizovat QoS incompatibility +``` + +### **Krok 3: Přidat vendor detection do test_suite.py** + +```python +def get_expected_codes_for_vendors(pub_name, sub_name, base_codes, test_name): + """ + Vrátí vendor-specific expected codes. + """ + # TimeBasedFilter - nepodporuje: Fast DDS, Micro + if 'time-filter' in test_name.lower(): + if 'fastdds' in sub_name or 'micro' in sub_name: + return [base_codes[0], ReturnCode.SUB_UNSUPPORTED_FEATURE] + + # Lifespan - nepodporuje: Micro + if 'lifespan' in test_name.lower(): + if 'micro' in pub_name: + return [ReturnCode.PUB_UNSUPPORTED_FEATURE, base_codes[1]] + + return base_codes +``` + +--- + +## 🎯 Konkrétní opravy v souborech + +Připravuji 3 patche: +1. **shape_main.cxx** - Upravit error handling pro unsupported features +2. **test_suite.py** - Aktualizovat expected_codes +3. **shape_configurator_eprosima_fast_dds.h** - Opravit Duration_t konverzi + +Chceš, aby jsem je vytvořil? + diff --git a/README.md b/README.md index ed94749..60042d6 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ and can interoperate with each other. * 3.4. [Report](#report) + * 3.5. [Running Tests in an Isolated Docker Container](#running-tests-in-an-isolated-docker-container) + * 4\. [Automation with GitHub Actions](#automation-with-github-actions) * 5\. [Workflow](#workflow) @@ -468,6 +470,42 @@ The report file contains the following items: * Console output from the shape application publisher and subscriber. +## Running Tests in an Isolated Docker Container + +Running interoperability tests inside Docker provides a self-contained, reproducible environment with all dependencies (Python 3, Node.js, `xunit-viewer`, `junitparser`, `XlsxWriter`, `lxml`) pre-installed. + +Furthermore, Docker's default bridge network isolates RTPS multicast discovery traffic (such as `239.255.0.1` and `224.0.0.0/4`) within the container, preventing multicast leaks to the local LAN and avoiding interference between concurrent test runs or other DDS applications on the network (which is particularly useful when running tests inside virtualized environments, LXC containers, or multi-tenant machines). + +### Quick Start (All-in-One Automated Run) + +Run all tests against executables in `./executables` and automatically generate XML, Excel (`.xlsx`), and HTML (`index.html`) reports: + +```bash +./run_tests_in_docker.sh +``` + +To run tests on a specific subset of executables or pass custom flags to `run_tests.sh`: + +```bash +./run_tests_in_docker.sh -p ./executables/connext_dds*shape_main_linux -s ./executables/opendds*shape_main_linux +``` + +### Interactive Shell or Custom Execution + +You can also launch an interactive shell in the Docker container: + +```bash +./run_in_docker.sh +``` + +Or run via Docker Compose: + +```bash +docker compose run --rm tester ./run_tests.sh -i ./executables +``` + +All generated reports (`junit_interoperability_report.xml`, `interoperability_report.xlsx`, `index.html`) are saved directly to the host workspace, and previous runs are automatically archived to `archive_reports/`. + # Automation with GitHub Actions GitHub Actions allows to automate the testing phase by calling @@ -507,6 +545,10 @@ To generate the report you should follow the next steps: ![Run-workflow](./resource/_md/img/img3.png) +> **Note on Runner Environment**: +> * **`ubuntu-latest`** *(default)*: Executes in GitHub Actions cloud. +> * **`self-hosted`**: Executes on your own self-hosted runner (e.g. inside an LXC container). In this mode, the workflow automatically runs tests inside an isolated Docker container (`run_in_docker.sh`) to prevent RTPS multicast discovery traffic from leaking into the physical local network. + 4. Wait a few minutes until the new task is finished and then press it.\ Example of a successful and a failed test: diff --git a/README_DISCOVERY.md b/README_DISCOVERY.md new file mode 100644 index 0000000..59eb581 --- /dev/null +++ b/README_DISCOVERY.md @@ -0,0 +1,128 @@ +# RTPS Discovery Sniffer a JUnit Discovery Report + +Nástroj pro odposlech a hloubkovou analýzu počátečního RTPS Discovery provozu (**SPDP** a **SEDP**) ve formátu **ParameterList (CDR)** podle OMG DDS-RTPS specifikace. Generuje samostatný JUnit XML report `junit_discovery_report_.xml` a strukturovaný JSON report `discovery_report_.json`. + +--- + +## 1. Vytvořené a upravené soubory + +- [`rtps_discovery_sniffer.py`](rtps_discovery_sniffer.py): Samostatný Python skript využívající `tshark` pro zachycení a dissekci RTPS discovery metatrafficu (SPDP na multicast portu 7400 a unicast/multicast SEDP). +- [`test_rtps_discovery_sniffer.py`](test_rtps_discovery_sniffer.py): Sada unit testů pokrývající dekódování parametrů, formátování časových značek, JUnit XML validaci a QoS kompatibilitu. +- [`Dockerfile`](Dockerfile): Doplněna instalace `tshark` s nastavením SUID práv (`chmod 4755 /usr/bin/dumpcap`) pro zachytávání síťových paketů v neprivilegovaném kontejneru. +- [`run_tests.sh`](run_tests.sh): Automatické spouštění discovery snifferu na pozadí během testování, podpora přepínačů `-d / --discovery-only`, `-t / --test` a `--skip-discovery`. +- [`run_tests_in_docker.sh`](run_tests_in_docker.sh): Předávání discovery parametrů, archivace discovery JSON reportů a spouštění kontejneru s `--cap-add=NET_ADMIN --cap-add=NET_RAW`. +- [`generate_reports.sh`](generate_reports.sh): Ošetřeno slučování JUnit XML reportů tak, aby discovery reporty zůstávaly oddělené od `junit_interoperability_report.xml`. +- [`.gitignore`](.gitignore): Doplněno ignorování generovaných discovery JSON reportů a dočasného souboru `timestamp`. + +--- + +## 2. Extrahované parametry z Discovery + +### SPDP (`DATA(p)` na portu 7400 / unicast metatraffic): +* **Vendor ID:** Dekódování hexadecimálního ID na čitelný název výrobce: + - RTI Connext DDS (`01.01`) + - Eclipse Cyclone DDS (`01.10`) + - eProsima Fast DDS (`01.0f`) + - OpenDDS (`01.03`) + - Twin Oaks CoreDX DDS (`01.05`) + - Dust DDS (`01.14`), RustDDS (`01.12`), GurumDDS (`01.11`) a další. +* **RTPS Protocol Version:** Extrakce verze protokolu (např. RTPS 2.1, 2.3, 2.4, 2.5). +* **Participant GUID Prefix:** 12bajtový unikátní identifikátor účastníka (24 hex znaků). +* **Locators:** IP adresy a porty (metatraffic i default data, unicast i multicast) s automatickým odstraněním duplicit. +* **Built-in Endpoints:** Dekódování bitové masky na jednotlivé discovery mechanismy (SEDP publication/subscription announcers/detectors, participant message datawriters/readers). +* **Lease Duration:** Časový limit platnosti účastníka v sekundách (Participant Lease Duration). + +### SEDP (`DATA(w)` a `DATA(r)`): +* **Topic Name & Type Name:** Názvy témat a IDL datových typů (např. topic `Square`, type `ShapeType`). +* **Endpoint GUID & Entity Kind:** Rozlišení `DataWriter` (0x02, 0x03) vs `DataReader` (0x04, 0x07). +* **QoS profily:** + - Reliability kind (`BEST_EFFORT` vs `RELIABLE`) + - Durability kind (`VOLATILE`, `TRANSIENT_LOCAL`, `TRANSIENT`, `PERSISTENT`) + - Ownership kind (`SHARED` vs `EXCLUSIVE`) + - History depth +* **TypeInformation / TypeObject:** Detekce přítomnosti XTypes metadat. + +--- + +## 3. Formát a struktura JUnit XML reportu + +Soubor: **`junit_discovery_report_.xml`** (např. `junit_discovery_report_2026-09-22-18_54_00.xml`) + +| TestCase | Účel | Podmínka selhání (Failure) | +|---|---|---| +| `Test_SPDP_Participant_Discovery` | Kontroluje detekci účastníků a jejich lokátorů | Počet účastníků `< expected_participants` | +| `Test_SPDP_Protocol_Version` | Ověřuje shodu s RTPS specifikací | Verze `< 2.1` nebo neplatný formát | +| `Test_SPDP_Vendor_Identification` | Identifikuje DDS stacky obou stran | Neznámé / nevalidní ID | +| `Test_SPDP_Builtin_Endpoints` | Kontroluje zapnutí discovery endpointů | Maska `0x00000000` | +| `Test_SEDP_Topic_Type_Match` | Ověřuje párování DataWriterů a DataReaderů | Neshoda v názvu tématu nebo typu (překlep v IDL) | +| `Test_SEDP_QoS_Compatibility` | Validuje pravidlo *Offered vs Requested* | Inkompatibilní QoS (např. Writer `BEST_EFFORT` vs Reader `RELIABLE`) | +| `Test_SEDP_Type_Information` | Reportuje přítomnost TypeObject / XTypes | Informativní test | + +Každý testcase obsahuje v elementu `` kompletní textový přehled discovery mapy, který vizualizují všechny standardní JUnit prohlížeče (např. `xunit-viewer` nebo GitHub Actions). + +--- + +## 4. Použití v praxi + +### A. Rychlý discovery test v Dockeru (~6 sekund): +Spustí pouze úvodní discovery test (`Test_Domain_0`) bez nutnosti čekat na všech 105 testů: +```bash +./run_tests_in_docker.sh \ + -p ./executables/connext_dds-7.7.0_shape_main_linux \ + -s ./executables/eclipse_cyclone-11.0.1_shape_main_linux \ + -d +``` + +### B. Kompletní testovací sada (interoperabilita + discovery): +Spustí celou sadu 105 interoperability testů a discovery sniffer automaticky zachytí počáteční discovery komunikaci: +```bash +./run_tests_in_docker.sh \ + -p ./executables/connext_dds-7.7.0_shape_main_linux \ + -s ./executables/eclipse_cyclone-11.0.1_shape_main_linux +``` + +### C. Samostatný odposlech z příkazové řádky: +```bash +# 1. Spustit discovery sniffer na pozadí (délka např. 5 sekund) +python3 rtps_discovery_sniffer.py -i any -d 5 & +SNIFFER_PID=$! + +# 2. Spustit oba DDS účastníky +./executables/connext_dds-7.7.0_shape_main_linux -P -t Square -d 0 -b & +PUB_PID=$! +./executables/eclipse_cyclone-11.0.1_shape_main_linux -S -t Square -d 0 -b & +SUB_PID=$! + +# 3. Počkat na dokončení snifferu +wait $SNIFFER_PID +``` + +### D. Analýza již existujícího PCAP souboru: +```bash +python3 rtps_discovery_sniffer.py -p /tmp/discovery_capture.pcap -t 20260922-19_02_32 +``` + +### E. Zobrazení discovery reportu v HTML přes xunit-viewer: +```bash +npx -y xunit-viewer --results=./junit_discovery_report_20260922-19_02_32.xml --output=discovery_report.html +``` + +--- + +## 5. Integrace do Dockeru a testovacího skriptu + +Skripty [`run_tests.sh`](run_tests.sh) i [`run_tests_in_docker.sh`](run_tests_in_docker.sh) podporují tyto parametry: + +* **`-d` / `--discovery-only`**: Spustí pouze discovery test (`Test_Domain_0`) v délce cca 6 sekund. +* **`-t` / `--test `**: Spustí jeden konkrétní test (např. `-t Test_Domain_0`). +* **`--skip-discovery`**: Přeskočí discovery sniffer a spustí pouze původní interoperability testy. +* **`--sniffer-duration `**: Určí délku odposlechu (výchozí: 15s pro celou sadu, 6s pro `-d`). + +Vygenerované soubory po dokončení testu: +- `junit_discovery_report_.xml` (JUnit XML pro CI / test reporty) +- `discovery_report_.json` (strukturovaná data pro jq / automatizované validace) +- `junit_interoperability_report.xml` (sloučený interoperability report) +- `interoperability_report.xlsx` (Excel report) +- `index.html` (HTML vizualizace interoperability reportu) + +Discovery reporty zůstávají oddělené a nejsou slučovány do `junit_interoperability_report.xml` díky filtru v `generate_reports.sh`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a889be7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + tester: + build: . + image: dds-rtps-tester + volumes: + - .:/workspace + working_dir: /workspace + # Default bridge network contains all RTPS multicast discovery within this container + command: ["/bin/bash"] diff --git a/generate_reports.sh b/generate_reports.sh new file mode 100755 index 0000000..4265659 --- /dev/null +++ b/generate_reports.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -e + +# Change to script directory +cd "$(dirname "$0")" + +# Remove previously merged XML report and Excel report to avoid conflict +rm -f junit_interoperability_report.xml interoperability_report.xlsx + +echo "[1/3] Merging XML reports into junit_interoperability_report.xml..." +reports_to_merge=() +shopt -s nullglob +for f in *.xml; do + if [[ "$f" != "junit_interoperability_report.xml" && "$f" != junit_discovery_report* ]]; then + reports_to_merge+=("$f") + fi +done +shopt -u nullglob + +if [ ${#reports_to_merge[@]} -gt 0 ]; then + python3 -m junitparser merge "${reports_to_merge[@]}" junit_interoperability_report.xml +fi + +echo "[2/3] Generating Excel report interoperability_report.xlsx..." +python3 generate_xlsx_report.py --input junit_interoperability_report.xml --output interoperability_report.xlsx + +echo "[3/3] Generating HTML report index.html..." +if command -v xunit-viewer &> /dev/null; then + xunit-viewer --results=./junit_interoperability_report.xml --output=./index.html +else + npx -y xunit-viewer --results=./junit_interoperability_report.xml --output=./index.html +fi + +echo "Done! Generated reports:" +ls -lh junit_interoperability_report.xml interoperability_report.xlsx index.html 2>/dev/null || true diff --git a/interoperability_report.py b/interoperability_report.py index adc4927..f13ff20 100644 --- a/interoperability_report.py +++ b/interoperability_report.py @@ -601,14 +601,78 @@ def run_test( for i in range(0, num_entities): if expected_codes[i] != return_codes[i]: # if any of the ReturnCode does not match with the expected code, - # there is an error. + # there is an error or unsupported feature. test_result_correct = False + is_unsupported = any( + rc in (ReturnCode.PUB_UNSUPPORTED_FEATURE, ReturnCode.SUB_UNSUPPORTED_FEATURE) + for rc in return_codes + ) + if test_result_correct: print(f'{test_case.name} : OK') + elif is_unsupported: + unsupported_reasons = [] + for i in range(num_entities): + if return_codes[i] in (ReturnCode.PUB_UNSUPPORTED_FEATURE, ReturnCode.SUB_UNSUPPORTED_FEATURE): + found_reason = None + for line in shape_main_application_output[i].splitlines(): + line_clean = remove_ansi_colors(line).strip() + if 'not supported' in line_clean.lower(): + found_reason = f'{entity_type[i]}: {line_clean}' + break + if found_reason: + unsupported_reasons.append(found_reason) + else: + unsupported_reasons.append(f'{entity_type[i]}: {return_codes[i].name}') + + reason_str = "; ".join(unsupported_reasons) if unsupported_reasons else "Feature not supported by DDS vendor" + print(f'{test_case.name} : UNSUPPORTED ({reason_str})') + + for i in range(0, num_entities): + print(f'{entity_type[i]} expected code: {expected_codes[i].name}; ' + f'Code found: {return_codes[i].name}') + + log_message(f'\nInformation about {entity_type[i]}:\n ' + f'{shape_main_application_output[i]} ', verbosity) + + # Change the '\n' and SIGINT chars to html
+ shape_main_application_output_edited.append( + shape_main_application_output[i] + .replace('\n', '
') + .replace(chr(3),'
')) + + # generate the table for the html code. + message = \ + ' ' \ + ' ' \ + ' ' \ + ' ' \ + ' ' + for i in range(num_entities): + message += \ + ' ' \ + f' ' \ + f' ' \ + f' ' \ + '' + message += '
' \ + ' Expected Code Code Produced
{entity_type[i]} {expected_codes[i].name} {return_codes[i].name}
' + for i in range(0, num_entities): + message += f' Information {entity_type[i]} ' \ + f'
{shape_main_application_output_edited[i]}
' + message = remove_ansi_colors(message) + test_case.result = [junitparser.Skipped(message)] + else: - print(f'{test_case.name} : ERROR') + failure_reasons = [] + for i in range(num_entities): + if return_codes[i] != expected_codes[i]: + failure_reasons.append(f'{entity_type[i]}: got {return_codes[i].name}, expected {expected_codes[i].name}') + reason_str = "; ".join(failure_reasons) + print(f'{test_case.name} : FAILED ({reason_str})') + for i in range(0, num_entities): print(f'{entity_type[i]} expected code: {expected_codes[i].name}; ' f'Code found: {return_codes[i].name}') @@ -622,6 +686,13 @@ def run_test( .replace('\n', '
') .replace(chr(3),'
')) + if return_codes[i] != expected_codes[i]: + for line in shape_main_application_output[i].splitlines(): + line_clean = remove_ansi_colors(line).strip() + if any(kw in line_clean.lower() for kw in ['failed to', 'error allocating', '[error]', 'error:']): + print(f' -> Reason from {entity_type[i]}: {line_clean}') + break + # generate the table for the html code. message = \ ' ' \ diff --git a/requirements.txt b/requirements.txt index ffdbb1b..ae54e1b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,4 @@ argcomplete==2.0.0 pexpect==4.8.0 junitparser==3.1.0 XlsxWriter==3.1.9 -lxml==5.1.0 +lxml>=5.2.0 diff --git a/rtps_discovery_sniffer.py b/rtps_discovery_sniffer.py new file mode 100755 index 0000000..c117ba0 --- /dev/null +++ b/rtps_discovery_sniffer.py @@ -0,0 +1,880 @@ +#!/usr/bin/env python3 +""" +RTPS Discovery Sniffer & JUnit/JSON Report Generator +OMG DDS-RTPS Interoperability Testing Suite + +Captures and parses RTPS discovery metatraffic (SPDP and SEDP ParameterLists in CDR format). +Extracts participant identity, runtime settings, endpoint topology, and QoS parameters. +Generates: + - junit_discovery_report_.xml (JUnit XML for CI / test reporting) + - discovery_report_.json (Structured JSON summary for jq / automated assertions) +""" + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Set, Tuple + +import junitparser + +# Known DDS Vendors mapping (RTPS VendorId) +# Standard vendor IDs registered with OMG +VENDOR_MAP: Dict[str, str] = { + "0101": "RTI Connext DDS", + "0102": "OpenSplice DDS", + "0103": "OpenDDS", + "0104": "MilSoft", + "0105": "Twin Oaks CoreDX DDS", + "0106": "Lakos", + "0107": "PrismTech", + "0108": "OAMAS", + "0109": "THALES", + "010a": "ATIS", + "010f": "eProsima Fast DDS", + "0110": "Eclipse Cyclone DDS", + "0111": "GurumNetworks GurumDDS", + "0112": "RustDDS", + "0113": "Shadow Security DDS", + "0114": "Dust DDS", + "0120": "Intercom DDS", +} + +# Builtin endpoint set bits (RTPS 2.5 Specification Table 9.4) +BUILTIN_ENDPOINTS_MAP = { + 0x00000001: "DISC_BUILTIN_ENDPOINT_PARTICIPANT_ANNOUNCER", + 0x00000002: "DISC_BUILTIN_ENDPOINT_PARTICIPANT_DETECTOR", + 0x00000004: "DISC_BUILTIN_ENDPOINT_PUBLICATION_ANNOUNCER", + 0x00000008: "DISC_BUILTIN_ENDPOINT_PUBLICATION_DETECTOR", + 0x00000010: "DISC_BUILTIN_ENDPOINT_SUBSCRIPTION_ANNOUNCER", + 0x00000020: "DISC_BUILTIN_ENDPOINT_SUBSCRIPTION_DETECTOR", + 0x00000040: "DISC_BUILTIN_ENDPOINT_PARTICIPANT_PROXY_ANNOUNCER", + 0x00000080: "DISC_BUILTIN_ENDPOINT_PARTICIPANT_PROXY_DETECTOR", + 0x00000100: "DISC_BUILTIN_ENDPOINT_PARTICIPANT_STATE_ANNOUNCER", + 0x00000200: "DISC_BUILTIN_ENDPOINT_PARTICIPANT_STATE_DETECTOR", + 0x00000400: "BUILTIN_ENDPOINT_PARTICIPANT_MESSAGE_DATA_WRITER", + 0x00000800: "BUILTIN_ENDPOINT_PARTICIPANT_MESSAGE_DATA_READER", + 0x00001000: "BITS_TYPE_LOOKUP_SERVICE_REQUEST_DATA_WRITER", + 0x00002000: "BITS_TYPE_LOOKUP_SERVICE_REQUEST_DATA_READER", + 0x00004000: "BITS_TYPE_LOOKUP_SERVICE_REPLY_DATA_WRITER", + 0x00008000: "BITS_TYPE_LOOKUP_SERVICE_REPLY_DATA_READER", + 0x00010000: "DISC_BUILTIN_ENDPOINT_PUBLICATION_SECURE_ANNOUNCER", + 0x00020000: "DISC_BUILTIN_ENDPOINT_PUBLICATION_SECURE_DETECTOR", + 0x00040000: "DISC_BUILTIN_ENDPOINT_SUBSCRIPTION_SECURE_ANNOUNCER", + 0x00080000: "DISC_BUILTIN_ENDPOINT_SUBSCRIPTION_SECURE_DETECTOR", + 0x00100000: "BUILTIN_ENDPOINT_PARTICIPANT_MESSAGE_SECURE_DATA_WRITER", + 0x00200000: "BUILTIN_ENDPOINT_PARTICIPANT_MESSAGE_SECURE_DATA_READER", + 0x00400000: "DISC_BUILTIN_ENDPOINT_PARTICIPANT_SECURE_ANNOUNCER", + 0x00800000: "DISC_BUILTIN_ENDPOINT_PARTICIPANT_SECURE_DETECTOR", +} + +RELIABILITY_KINDS = { + 1: "BEST_EFFORT", + 2: "RELIABLE", +} + +DURABILITY_KINDS = { + 0: "VOLATILE", + 1: "TRANSIENT_LOCAL", + 2: "TRANSIENT", + 3: "PERSISTENT", +} + +OWNERSHIP_KINDS = { + 0: "SHARED", + 1: "EXCLUSIVE", +} + + +def normalize_hex(val: Any, length: int = 4) -> str: + """Normalize hex string or int to lowercase hex digits without '0x'.""" + if val is None: + return "" + if isinstance(val, int): + return f"{val:0{length}x}" + s = str(val).strip().lower() + if s.startswith("0x"): + s = s[2:] + s = s.replace(":", "").replace(".", "") + return s.zfill(length) + + +def format_guid(raw_guid: Any) -> str: + """Format GUID prefix or full GUID to clean hex string.""" + if not raw_guid: + return "unknown" + if isinstance(raw_guid, list): + raw_guid = raw_guid[0] + s = str(raw_guid).strip().lower().replace(":", "").replace(".", "") + if s.startswith("0x"): + s = s[2:] + return s + + +def decode_vendor(vendor_val: Any) -> Tuple[str, str]: + """Return (vendor_hex_id, vendor_human_name).""" + norm = normalize_hex(vendor_val, length=4) + name = VENDOR_MAP.get(norm, f"Unknown DDS Vendor ({norm})") + formatted = f"{norm[:2]}.{norm[2:]}" if len(norm) == 4 else norm + return formatted, name + + +def decode_version(ver_val: Any) -> str: + """Decode protocol version from hex (e.g. 0x0204 -> 2.4).""" + if ver_val is None: + return "unknown" + if isinstance(ver_val, (int, float)): + val_int = int(ver_val) + major = (val_int >> 8) & 0xFF + minor = val_int & 0xFF + return f"{major}.{minor}" + s = str(ver_val).strip() + if s.startswith("0x") and len(s) >= 6: + try: + val_int = int(s, 16) + major = (val_int >> 8) & 0xFF + minor = val_int & 0xFF + return f"{major}.{minor}" + except ValueError: + pass + return s + + +def decode_builtin_endpoints(mask_val: Any) -> Tuple[str, List[str]]: + """Decode built-in endpoints bitmask into hex string and flag names.""" + if mask_val is None: + return "0x00000000", [] + try: + val_int = int(mask_val, 16) if isinstance(mask_val, str) else int(mask_val) + except (ValueError, TypeError): + return str(mask_val), [] + flags = [name for bit, name in BUILTIN_ENDPOINTS_MAP.items() if (val_int & bit) != 0] + return f"0x{val_int:08x}", flags + + +def get_current_timestamp() -> str: + """ + Get timestamp aligned with interoperability reports (%Y%m%d-%H_%M_%S). + 1. Read './timestamp' file if present. + 2. Fallback to current datetime '%Y%m%d-%H_%M_%S'. + """ + timestamp_file = Path("timestamp") + if timestamp_file.is_file(): + try: + content = timestamp_file.read_text().strip() + if content: + m = re.match(r"^(\d{4})-(\d{2})-(\d{2})-(.*)$", content) + if m: + return f"{m.group(1)}{m.group(2)}{m.group(3)}-{m.group(4)}" + return content + except Exception: + pass + return datetime.now().strftime("%Y%m%d-%H_%M_%S") + + +def capture_traffic_pcap(interface: str, duration_sec: int, output_pcap: str) -> bool: + """Capture raw RTPS UDP packets to a pcap file using tshark.""" + if not shutil.which("tshark"): + raise RuntimeError( + "tshark is not installed. Install it via 'apt-get install -y tshark'." + ) + + cmd = [ + "tshark", + "-i", + interface, + "-a", + f"duration:{duration_sec}", + "-f", + "udp", + "-w", + output_pcap, + ] + try: + proc = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=duration_sec + 10, + ) + return os.path.exists(output_pcap) and os.path.getsize(output_pcap) > 0 + except subprocess.TimeoutExpired: + return os.path.exists(output_pcap) and os.path.getsize(output_pcap) > 0 + + +def dump_rtps_json_from_pcap(pcap_path: str) -> List[Dict[str, Any]]: + """Dissect RTPS packets from pcap using tshark with full JSON output.""" + cmd = [ + "tshark", + "--no-duplicate-keys", + "-r", + pcap_path, + "-Y", + "rtps", + "-T", + "json", + ] + proc = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if proc.returncode != 0 or not proc.stdout.strip(): + return [] + try: + return json.loads(proc.stdout) + except json.JSONDecodeError: + return [] + + +def parse_discovery_data(raw_packets: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Analyze JSON packets dissected by Wireshark RTPS dissector. + Extracts: + - Participants (SPDP): GUID prefix, IP, vendor ID, version, locators, lease, builtin endpoints + - Endpoints (SEDP): Entity GUID, entity kind, topic name, type name, QoS parameters + """ + summary: Dict[str, Any] = { + "metadata": { + "total_rtps_packets": len(raw_packets), + "analyzed_at": datetime.now().isoformat(), + }, + "participants": {}, + "endpoints": [], + } + + def walk_tree(obj: Any, callback): + """Recursively traverse the Wireshark dissection tree.""" + if isinstance(obj, dict): + callback(obj) + for v in obj.values(): + walk_tree(v, callback) + elif isinstance(obj, list): + for item in obj: + walk_tree(item, callback) + + for pkt in raw_packets: + source = pkt.get("_source", {}) + layers = source.get("layers", {}) + rtps = layers.get("rtps", {}) + ip_layer = layers.get("ip", {}) + src_ip = ip_layer.get("ip.src", layers.get("ip.src", "unknown")) + dst_ip = ip_layer.get("ip.dst", layers.get("ip.dst", "unknown")) + if isinstance(src_ip, list): + src_ip = src_ip[0] + if isinstance(dst_ip, list): + dst_ip = dst_ip[0] + + # Extract Header fields + header_guid = rtps.get("rtps.guidPrefix", rtps.get("rtps.guidPrefix.src")) + header_vendor = rtps.get("rtps.vendorId") + header_version = rtps.get("rtps.version") + + # ---------------------------------------------------- + # SPDP Participant Discovery Detection + # ---------------------------------------------------- + # Look for PID_PARTICIPANT_GUID, PID_PROTOCOL_VERSION, PID_VENDOR_ID + found_spdp: Dict[str, Any] = {} + locators_found: List[Dict[str, Any]] = [] + + def inspect_spdp_node(node: Dict[str, Any]): + for k, v in node.items(): + k_lower = k.lower() + + # Participant GUID + if "pid_participant_guid" in k_lower or k == "rtps.param.participant_guid": + guid_val = node.get("rtps.param.participant_guid", v) + guid_str = format_guid(guid_val) + # 12 bytes = 24 hex chars prefix + prefix = guid_str[:24] if len(guid_str) >= 24 else guid_str + found_spdp["guid_prefix"] = prefix + + # Vendor ID + if "pid_vendor_id" in k_lower or k == "rtps.vendorId": + v_val = node.get("rtps.vendorId", v) + v_hex, v_name = decode_vendor(v_val) + found_spdp["vendor_id"] = v_hex + found_spdp["vendor_name"] = v_name + + # Protocol Version + if "pid_protocol_version" in k_lower or k == "rtps.version": + ver_val = node.get("rtps.version", v) + found_spdp["protocol_version"] = decode_version(ver_val) + + # Lease Duration + if "pid_participant_lease_duration" in k_lower or "lease_duration" in k_lower: + sec = node.get("rtps.param.ntpTime.sec") + if sec is not None: + try: + found_spdp["lease_duration_sec"] = float(sec) + except (ValueError, TypeError): + pass + + # Built-in endpoints + if "pid_builtin_endpoint_set" in k_lower or "builtin_endpoint_set" in k_lower: + mask = node.get("rtps.param.builtin_endpoint_set", v) + mask_hex, flags = decode_builtin_endpoints(mask) + found_spdp["builtin_endpoints_mask"] = mask_hex + found_spdp["builtin_endpoints_flags"] = flags + + # Locators + if "locator" in k_lower and isinstance(node, dict): + loc_ip = node.get("rtps.locator.ipv4") or node.get("rtps.locator_udp_v4.ip") + loc_port = node.get("rtps.locator.port") or node.get("rtps.locator_udp_v4.port") + if loc_ip and loc_port: + kind = "unicast" + if "metatraffic" in k_lower: + kind = "metatraffic_unicast" + elif "multicast" in k_lower: + kind = "multicast" + locators_found.append({ + "type": kind, + "ip": str(loc_ip), + "port": int(str(loc_port).split()[0], 0) if str(loc_port).isdigit() or str(loc_port).startswith("0x") else str(loc_port), + }) + + walk_tree(rtps, inspect_spdp_node) + + # Fallback to header values if parameter wasn't explicitly captured + guid_p = found_spdp.get("guid_prefix") or (format_guid(header_guid)[:24] if header_guid else None) + if guid_p and guid_p not in summary["participants"]: + v_hex, v_name = decode_vendor(found_spdp.get("vendor_id") or header_vendor) + proto_ver = found_spdp.get("protocol_version") or decode_version(header_version) + unique_locs = [] + seen_locs = set() + for loc in locators_found: + lk = (loc["type"], loc["ip"], loc["port"]) + if lk not in seen_locs: + seen_locs.add(lk) + unique_locs.append(loc) + summary["participants"][guid_p] = { + "guid_prefix": guid_p, + "src_ip": src_ip, + "vendor_id": v_hex, + "vendor_name": v_name, + "protocol_version": proto_ver, + "lease_duration_sec": found_spdp.get("lease_duration_sec", 20.0), + "builtin_endpoints_mask": found_spdp.get("builtin_endpoints_mask", "unknown"), + "builtin_endpoints_flags": found_spdp.get("builtin_endpoints_flags", []), + "locators": unique_locs, + } + elif guid_p and guid_p in summary["participants"]: + # Augment existing participant with newly seen locators or flags + part = summary["participants"][guid_p] + if not part.get("builtin_endpoints_flags") and found_spdp.get("builtin_endpoints_flags"): + part["builtin_endpoints_mask"] = found_spdp["builtin_endpoints_mask"] + part["builtin_endpoints_flags"] = found_spdp["builtin_endpoints_flags"] + if found_spdp.get("lease_duration_sec"): + part["lease_duration_sec"] = found_spdp["lease_duration_sec"] + existing_locs = {(l["type"], l["ip"], l["port"]) for l in part["locators"]} + for loc in locators_found: + lk = (loc["type"], loc["ip"], loc["port"]) + if lk not in existing_locs: + existing_locs.add(lk) + part["locators"].append(loc) + + # ---------------------------------------------------- + # SEDP Endpoint Discovery Detection (DATA(w) / DATA(r)) + # ---------------------------------------------------- + def extract_sedp_endpoints(node: Any): + if isinstance(node, dict): + s_str = json.dumps(node) + if "rtps.param.topicName" in s_str and ("rtps.param.endpoint_guid" in s_str or "PID_ENDPOINT_GUID" in s_str): + cand: Dict[str, Any] = {} + + def inspect_cand(n: Any): + if isinstance(n, dict): + for k, v in n.items(): + if k == "rtps.param.topicName": + cand["topic_name"] = str(v) + elif k == "rtps.param.typeName": + cand["type_name"] = str(v) + elif k == "rtps.param.endpoint_guid": + guid_full = format_guid(v) + if len(guid_full) >= 24: + cand["endpoint_guid"] = guid_full + kind_hex = guid_full[-2:] + if kind_hex in ("02", "03"): + cand["entity_kind"] = "DataWriter" + elif kind_hex in ("04", "07"): + cand["entity_kind"] = "DataReader" + else: + cand["entity_kind"] = f"Entity (0x{kind_hex})" + elif k == "rtps.reliability_kind": + try: + rk_int = int(str(v).split()[0], 0) + cand["reliability"] = RELIABILITY_KINDS.get(rk_int, f"0x{rk_int:x}") + except (ValueError, TypeError): + cand["reliability"] = str(v) + elif k == "rtps.durability": + try: + dk_int = int(str(v).split()[0], 0) + cand["durability"] = DURABILITY_KINDS.get(dk_int, f"0x{dk_int:x}") + except (ValueError, TypeError): + cand["durability"] = str(v) + elif k == "rtps.ownership": + try: + ok_int = int(str(v).split()[0], 0) + cand["ownership"] = OWNERSHIP_KINDS.get(ok_int, f"0x{ok_int:x}") + except (ValueError, TypeError): + cand["ownership"] = str(v) + elif k in ("rtps.history_depth", "rtps.param.history.depth"): + try: + cand["history_depth"] = int(str(v).split()[0], 0) + except (ValueError, TypeError): + pass + elif "typeobject" in k.lower() or "type_information" in k.lower() or "pid_type_object" in k.lower(): + cand["has_type_object"] = True + inspect_cand(v) + elif isinstance(n, list): + for item in n: + inspect_cand(item) + + inspect_cand(node) + if cand.get("topic_name") and cand.get("type_name") and cand.get("endpoint_guid"): + ep_guid = cand["endpoint_guid"] + if "reliability" not in cand: + cand["reliability"] = "BEST_EFFORT" + if "durability" not in cand: + cand["durability"] = "VOLATILE" + if "ownership" not in cand: + cand["ownership"] = "SHARED" + cand["src_ip"] = src_ip + existing = [ + ep for ep in summary["endpoints"] + if ep.get("endpoint_guid") == ep_guid and ep.get("topic_name") == cand["topic_name"] + ] + if not existing: + summary["endpoints"].append(cand) + return + + for v in node.values(): + extract_sedp_endpoints(v) + elif isinstance(node, list): + for item in node: + extract_sedp_endpoints(item) + + extract_sedp_endpoints(rtps) + + return summary + + +def generate_junit_report( + summary: Dict[str, Any], + output_xml_path: str, + publisher_name: Optional[str] = None, + subscriber_name: Optional[str] = None, + expected_participants: int = 2, + expected_topic: Optional[str] = None, + expected_type: Optional[str] = None, +) -> junitparser.JUnitXml: + """ + Build a comprehensive JUnit XML report evaluating the discovered RTPS topology. + Writes report to output_xml_path. + """ + if os.path.exists(output_xml_path): + try: + xml = junitparser.JUnitXml.fromfile(output_xml_path) + except Exception: + xml = junitparser.JUnitXml() + else: + xml = junitparser.JUnitXml() + suite_title = "rtps_discovery_report" + if publisher_name and subscriber_name: + suite_title = f"rtps_discovery_{publisher_name}---{subscriber_name}" + suite = junitparser.TestSuite(suite_title) + + participants = summary.get("participants", {}) + endpoints = summary.get("endpoints", []) + part_count = len(participants) + + # ---------------------------------------------------- + # TestCase 1: SPDP Participant Discovery + # ---------------------------------------------------- + tc_part = junitparser.TestCase("Test_SPDP_Participant_Discovery") + tc_part.time = 0.1 + part_desc = [f"Discovered {part_count} participant(s):"] + for guid, p in participants.items(): + loc_str = ", ".join(f"{l['type']}={l['ip']}:{l['port']}" for l in p.get("locators", [])) + part_desc.append( + f" - GUID Prefix: {guid}\n" + f" Source IP: {p.get('src_ip')}\n" + f" Vendor: {p.get('vendor_name')} ({p.get('vendor_id')})\n" + f" Protocol Version: {p.get('protocol_version')}\n" + f" Lease Duration: {p.get('lease_duration_sec')}s\n" + f" Locators: {loc_str or 'none'}" + ) + tc_part.system_out = "\n".join(part_desc) + + if part_count < expected_participants: + msg = ( + f"Expected at least {expected_participants} participant(s), " + f"but found {part_count}." + ) + failure = junitparser.Failure(msg) + tc_part.result = [failure] + suite.add_testcase(tc_part) + + # ---------------------------------------------------- + # TestCase 2: RTPS Protocol Version Compliance + # ---------------------------------------------------- + tc_ver = junitparser.TestCase("Test_SPDP_Protocol_Version") + tc_ver.time = 0.05 + ver_issues = [] + ver_lines = [] + for guid, p in participants.items(): + ver_str = str(p.get("protocol_version", "unknown")) + ver_lines.append(f"Participant {guid[:12]}: RTPS {ver_str}") + try: + parts = [int(x) for x in ver_str.split(".")] + if parts[0] < 2 or (parts[0] == 2 and parts[1] < 1): + ver_issues.append(f"Participant {guid} has non-standard version RTPS {ver_str}") + except Exception: + ver_issues.append(f"Invalid version string '{ver_str}' for participant {guid}") + + tc_ver.system_out = "\n".join(ver_lines) + if ver_issues: + tc_ver.result = [junitparser.Failure("; ".join(ver_issues))] + suite.add_testcase(tc_ver) + + # ---------------------------------------------------- + # TestCase 3: Vendor Identification + # ---------------------------------------------------- + tc_vendor = junitparser.TestCase("Test_SPDP_Vendor_Identification") + tc_vendor.time = 0.05 + vendor_lines = [] + for guid, p in participants.items(): + vendor_lines.append( + f"Participant {guid[:12]}: {p.get('vendor_name')} [ID: {p.get('vendor_id')}]" + ) + tc_vendor.system_out = "\n".join(vendor_lines) + suite.add_testcase(tc_vendor) + + # ---------------------------------------------------- + # TestCase 4: Built-in Endpoints Capabilities + # ---------------------------------------------------- + tc_builtin = junitparser.TestCase("Test_SPDP_Builtin_Endpoints") + tc_builtin.time = 0.05 + builtin_lines = [] + builtin_issues = [] + for guid, p in participants.items(): + mask = p.get("builtin_endpoints_mask") + flags = p.get("builtin_endpoints_flags", []) + builtin_lines.append( + f"Participant {guid[:12]} Built-in Mask: {mask}\n" + f" Flags: {', '.join(flags) if flags else 'None or Default'}" + ) + if mask == "0x00000000": + builtin_issues.append(f"Participant {guid} reported empty builtin endpoint set") + tc_builtin.system_out = "\n".join(builtin_lines) + if builtin_issues: + tc_builtin.result = [junitparser.Failure("; ".join(builtin_issues))] + suite.add_testcase(tc_builtin) + + # ---------------------------------------------------- + # TestCase 5: SEDP Topic & Type Matching + # ---------------------------------------------------- + tc_sedp = junitparser.TestCase("Test_SEDP_Topic_Type_Match") + tc_sedp.time = 0.1 + sedp_lines = [f"Discovered {len(endpoints)} endpoint(s):"] + topics_seen: Set[str] = set() + types_seen: Set[str] = set() + writers: List[Dict[str, Any]] = [] + readers: List[Dict[str, Any]] = [] + + for ep in endpoints: + kind = ep.get("entity_kind", "Endpoint") + topic = ep.get("topic_name", "unknown") + t_type = ep.get("type_name", "unknown") + topics_seen.add(topic) + types_seen.add(t_type) + if "writer" in kind.lower(): + writers.append(ep) + elif "reader" in kind.lower(): + readers.append(ep) + sedp_lines.append( + f" - {kind}: Topic='{topic}', Type='{t_type}', GUID={ep.get('endpoint_guid')}\n" + f" QoS: Reliability={ep.get('reliability')}, Durability={ep.get('durability')}, Ownership={ep.get('ownership')}" + ) + + tc_sedp.system_out = "\n".join(sedp_lines) + + mismatch_errors = [] + if expected_topic and expected_topic not in topics_seen: + mismatch_errors.append(f"Expected topic '{expected_topic}' not found in discovery (seen: {topics_seen})") + if expected_type and expected_type not in types_seen: + mismatch_errors.append(f"Expected type '{expected_type}' not found in discovery (seen: {types_seen})") + + # If both writers and readers exist, verify topic and type consistency + if writers and readers: + w_topics = {w.get("topic_name") for w in writers} + r_topics = {r.get("topic_name") for r in readers} + if not (w_topics & r_topics): + mismatch_errors.append(f"Mismatched topics between Writer(s) {w_topics} and Reader(s) {r_topics}") + + w_types = {w.get("type_name") for w in writers} + r_types = {r.get("type_name") for r in readers} + if not (w_types & r_types): + mismatch_errors.append(f"Mismatched types between Writer(s) {w_types} and Reader(s) {r_types}") + + if mismatch_errors: + tc_sedp.result = [junitparser.Failure("; ".join(mismatch_errors))] + suite.add_testcase(tc_sedp) + + # ---------------------------------------------------- + # TestCase 6: SEDP QoS Compatibility + # ---------------------------------------------------- + tc_qos = junitparser.TestCase("Test_SEDP_QoS_Compatibility") + tc_qos.time = 0.05 + qos_lines = ["Evaluating QoS profiles between DataWriter and DataReader:"] + qos_errors = [] + + if writers and readers: + for w in writers: + for r in readers: + w_rel = w.get("reliability", "BEST_EFFORT") + r_rel = r.get("reliability", "BEST_EFFORT") + w_dur = w.get("durability", "VOLATILE") + r_dur = r.get("durability", "VOLATILE") + + qos_lines.append( + f"Pairing Writer ({w.get('endpoint_guid')}) [{w_rel}, {w_dur}] vs " + f"Reader ({r.get('endpoint_guid')}) [{r_rel}, {r_dur}]" + ) + + # Reliability rule: Offered >= Requested (RELIABLE offers to BEST_EFFORT or RELIABLE; BEST_EFFORT cannot offer to RELIABLE) + if w_rel == "BEST_EFFORT" and r_rel == "RELIABLE": + qos_errors.append( + f"Incompatible Reliability: Writer is BEST_EFFORT but Reader requested RELIABLE" + ) + + # Durability rule: Offered >= Requested + dur_order = {"VOLATILE": 0, "TRANSIENT_LOCAL": 1, "TRANSIENT": 2, "PERSISTENT": 3} + w_level = dur_order.get(w_dur, 0) + r_level = dur_order.get(r_dur, 0) + if w_level < r_level: + qos_errors.append( + f"Incompatible Durability: Writer offered {w_dur} but Reader requested {r_dur}" + ) + else: + qos_lines.append("Single-sided endpoint or no paired endpoints detected in capture window.") + + tc_qos.system_out = "\n".join(qos_lines) + if qos_errors: + tc_qos.result = [junitparser.Failure("; ".join(qos_errors))] + suite.add_testcase(tc_qos) + + # ---------------------------------------------------- + # TestCase 7: XTypes / TypeInformation + # ---------------------------------------------------- + tc_xtypes = junitparser.TestCase("Test_SEDP_Type_Information") + tc_xtypes.time = 0.05 + xtypes_present = any(ep.get("has_type_object") for ep in endpoints) + tc_xtypes.system_out = ( + f"XTypes TypeInformation/TypeObject detected: {'Yes' if xtypes_present else 'No (Standard CDR)'}" + ) + suite.add_testcase(tc_xtypes) + + xml.add_testsuite(suite) + xml.write(output_xml_path) + return xml + + +def run_discovery_sniffer(args: argparse.Namespace) -> None: + """Main execution orchestrator.""" + timestamp = args.timestamp or get_current_timestamp() + + # Determine output XML name + if args.output: + output_xml = args.output + else: + output_xml = f"junit_discovery_report_{timestamp}.xml" + + # Determine output JSON name + if args.json_output: + output_json = args.json_output + else: + output_json = f"discovery_report_{timestamp}.json" + + temp_pcap_path = None + input_pcap = args.pcap + + if not input_pcap: + # Perform live capture + if args.write_pcap: + pcap_file = args.write_pcap + else: + fd, temp_pcap_path = tempfile.mkstemp(prefix="rtps_capture_", suffix=".pcap") + os.close(fd) + pcap_file = temp_pcap_path + + print(f"[*] Starting live RTPS capture on interface '{args.interface}' for {args.duration}s...") + captured = capture_traffic_pcap(args.interface, args.duration, pcap_file) + if not captured: + print("[-] No UDP/RTPS packets captured during the listening window.") + input_pcap = pcap_file + else: + print(f"[*] Reading existing PCAP file: {input_pcap}") + + raw_packets = dump_rtps_json_from_pcap(input_pcap) + print(f"[+] Dissected {len(raw_packets)} RTPS packets with tshark.") + + summary = parse_discovery_data(raw_packets) + summary["metadata"]["interface"] = args.interface + summary["metadata"]["timestamp"] = timestamp + if args.publisher: + summary["metadata"]["publisher"] = args.publisher + if args.subscriber: + summary["metadata"]["subscriber"] = args.subscriber + + # Save JSON summary + to_write: Any = summary + if os.path.exists(output_json): + try: + with open(output_json, "r") as f: + existing = json.load(f) + pair_key = ( + f"{args.publisher}---{args.subscriber}" + if args.publisher and args.subscriber + else f"run_{timestamp}" + ) + if isinstance(existing, dict) and "runs" in existing: + existing["runs"][pair_key] = summary + to_write = existing + elif isinstance(existing, dict): + first_pub = existing.get("metadata", {}).get("publisher", "") + first_sub = existing.get("metadata", {}).get("subscriber", "") + first_key = f"{first_pub}---{first_sub}" if (first_pub and first_sub) else "run_1" + to_write = { + "metadata": {"timestamp": timestamp}, + "runs": { + first_key: existing, + pair_key: summary, + }, + } + except Exception: + to_write = summary + + with open(output_json, "w") as f: + json.dump(to_write, f, indent=2) + print(f"[+] Discovery JSON summary saved to: {output_json}") + + # Generate JUnit XML Report + generate_junit_report( + summary=summary, + output_xml_path=output_xml, + publisher_name=args.publisher, + subscriber_name=args.subscriber, + expected_participants=args.expected_participants, + expected_topic=args.expected_topic, + expected_type=args.expected_type, + ) + print(f"[+] Discovery JUnit XML report saved to: {output_xml}") + + # Display short summary + part_count = len(summary["participants"]) + ep_count = len(summary["endpoints"]) + print(f"\n==================== Discovery Summary ====================") + print(f"Discovered Participants: {part_count}") + for guid, p in summary["participants"].items(): + print(f" - [{p.get('vendor_name')}] GUID: {guid} (IP: {p.get('src_ip')}, Ver: {p.get('protocol_version')})") + print(f"Discovered Endpoints: {ep_count}") + for ep in summary["endpoints"]: + print(f" - {ep.get('entity_kind')}: Topic='{ep.get('topic_name')}' Type='{ep.get('type_name')}' (QoS: {ep.get('reliability')}, {ep.get('durability')})") + print(f"===========================================================\n") + + # Cleanup temp pcap if used + if temp_pcap_path and os.path.exists(temp_pcap_path): + os.remove(temp_pcap_path) + + +def main(): + parser = argparse.ArgumentParser( + description="RTPS Discovery Sniffer & JUnit/JSON Report Generator" + ) + parser.add_argument( + "-i", + "--interface", + default="any", + help="Network interface to listen on (e.g. any, lo, eth0). Default: any", + ) + parser.add_argument( + "-d", + "--duration", + type=int, + default=5, + help="Capture duration in seconds. Default: 5", + ) + parser.add_argument( + "-o", + "--output", + default=None, + help="Output JUnit XML report file name (default: junit_discovery_report_.xml)", + ) + parser.add_argument( + "-j", + "--json-output", + default=None, + help="Output JSON summary file name (default: discovery_report_.json)", + ) + parser.add_argument( + "-t", + "--timestamp", + default=None, + help="Explicit timestamp for report naming (matches ./timestamp file format)", + ) + parser.add_argument( + "-p", + "--pcap", + default=None, + help="Read and analyze an existing PCAP file instead of live capture", + ) + parser.add_argument( + "-w", + "--write-pcap", + default=None, + help="Write live captured packets to specified PCAP file", + ) + parser.add_argument( + "-P", + "--publisher", + default=None, + help="Publisher implementation or executable name", + ) + parser.add_argument( + "-S", + "--subscriber", + default=None, + help="Subscriber implementation or executable name", + ) + parser.add_argument( + "--expected-participants", + type=int, + default=2, + help="Expected number of participants for discovery pass/fail test (default: 2)", + ) + parser.add_argument( + "--expected-topic", + default=None, + help="Expected topic name to validate (e.g. Square)", + ) + parser.add_argument( + "--expected-type", + default=None, + help="Expected type name to validate (e.g. ShapeType)", + ) + + args = parser.parse_args() + run_discovery_sniffer(args) + + +if __name__ == "__main__": + main() diff --git a/run_in_docker.sh b/run_in_docker.sh new file mode 100755 index 0000000..2ee3a94 --- /dev/null +++ b/run_in_docker.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# ============================================================================== +# Helper script to run DDS-RTPS interoperability tests inside an isolated Docker container +# ============================================================================== +set -e + +IMAGE_NAME="dds-rtps-tester" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cd "$SCRIPT_DIR" + +# Check if Docker is installed +if ! command -v docker &> /dev/null; then + echo "ERROR: 'docker' command not found. Please install Docker first." + exit 1 +fi + +# Build Docker image if requested or if it does not exist +if [[ "$1" == "--build" ]] || [[ "$(docker images -q "$IMAGE_NAME" 2> /dev/null)" == "" ]]; then + echo "==> Building Docker image: $IMAGE_NAME..." + docker build -t "$IMAGE_NAME" . + if [[ "$1" == "--build" ]]; then + shift + fi +fi + +# Archive previous test results on host if interactive +if [ $# -eq 0 ]; then + archive_dir="$SCRIPT_DIR/archive_reports" + shopt -s nullglob + old_reports=("$SCRIPT_DIR"/*.xml "$SCRIPT_DIR"/*.xlsx "$SCRIPT_DIR"/index.html) + if [ ${#old_reports[@]} -gt 0 ]; then + echo "==> Archiving previous test reports to archive_reports/..." + mkdir -p "$archive_dir" + mv "${old_reports[@]}" "$archive_dir/" 2>/dev/null || true + fi + shopt -u nullglob +fi + +echo "==> Running in isolated Docker container (bridge network, contained multicast)..." + +# Detect if running in an interactive terminal (allocate pseudo-TTY only if interactive) +DOCKER_FLAGS="-i" +if [ -t 0 ] && [ -t 1 ]; then + DOCKER_FLAGS="-it" +fi + +# Run container: +# - Mount current directory to /workspace so test reports are saved to host +# - Default bridge network isolates multicast discovery (239.255.0.1) from local LAN +# - Runs with current host user UID:GID and PYTHONDONTWRITEBYTECODE=1 to avoid permission issues +# - Passes any additional arguments directly to the container command +if [ $# -eq 0 ]; then + docker run --rm $DOCKER_FLAGS \ + --cap-add=NET_ADMIN \ + --cap-add=NET_RAW \ + --user "$(id -u):$(id -g)" \ + -e PYTHONDONTWRITEBYTECODE=1 \ + -v "$SCRIPT_DIR:/workspace" \ + -w /workspace \ + "$IMAGE_NAME" \ + /bin/bash +else + docker run --rm $DOCKER_FLAGS \ + --cap-add=NET_ADMIN \ + --cap-add=NET_RAW \ + --user "$(id -u):$(id -g)" \ + -e PYTHONDONTWRITEBYTECODE=1 \ + -v "$SCRIPT_DIR:/workspace" \ + -w /workspace \ + "$IMAGE_NAME" \ + "$@" +fi diff --git a/run_tests.sh b/run_tests.sh index e12659d..36454e0 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -5,6 +5,10 @@ input="." publisher="" subscriber="" output="" +discovery_only=false +skip_discovery=false +single_test="" +sniffer_duration="" # Function to display usage information usage() { @@ -14,16 +18,22 @@ usage() { echo "subscriber is not provided, this script will find and use all " echo "'*_shape_main_linux' applications in the input directory as publisher and" echo "subscribers." - echo "Usage: $0 [-p publisher] [-s subscriber] [-o output] [-i input] [-h]" + echo "Usage: $0 [-p publisher] [-s subscriber] [-o output] [-i input] [-d] [-t test] [-h]" echo "Options:" - echo " -p, --publisher Specify the publisher application" - echo " -s, --subscriber Specify the subscriber application" - echo " -o, --output Specify the output XML file" - echo " -i, --input Specify the directory where publisher/subscriber applications are located (only if -p and -s are not provided)" - echo " -h, --help Print this help message" + echo " -p, --publisher Specify the publisher application" + echo " -s, --subscriber Specify the subscriber application" + echo " -o, --output Specify the output XML file" + echo " -i, --input Specify the directory where publisher/subscriber applications are located (only if -p and -s are not provided)" + echo " -d, --discovery-only Run only the discovery test case (Test_Domain_0) to quickly verify RTPS discovery" + echo " -t, --test Specify a single test case to run (e.g. Test_Domain_0)" + echo " --skip-discovery Skip running RTPS discovery sniffer" + echo " --sniffer-duration Capture duration in seconds for sniffer (default: 15, or 6 for discovery-only)" + echo " -h, --help Print this help message" echo "Examples:" echo "Run Connext as publisher and all executables under './executables' as subscribers" echo " ./run_tests.sh -p connext_dds-6.1.2_shape_main_linux -i ./executables" + echo "Run discovery test only between Connext and Cyclone DDS:" + echo " ./run_tests.sh -p connext_dds-7.7.0_shape_main_linux -s eclipse_cyclone-11.0.1_shape_main_linux -d" exit 1 } @@ -46,6 +56,22 @@ while [[ $# -gt 0 ]]; do input="$2" shift 2 ;; + -d|--discovery-only) + discovery_only=true + shift 1 + ;; + -t|--test) + single_test="$2" + shift 2 + ;; + --skip-discovery) + skip_discovery=true + shift 1 + ;; + --sniffer-duration) + sniffer_duration="$2" + shift 2 + ;; -h|--help) usage ;; @@ -74,21 +100,86 @@ if [[ -z $publisher || -z $subscriber ]]; then usage fi +# Archive previous test results if present +archive_dir="./archive_reports" +shopt -s nullglob +old_reports=(*.xml *.xlsx index.html discovery_report_*.json discovery_summary.json timestamp) +if [ ${#old_reports[@]} -gt 0 ]; then + echo "Archiving previous test reports to $archive_dir..." + mkdir -p "$archive_dir" + mv "${old_reports[@]}" "$archive_dir/" 2>/dev/null || true +fi +shopt -u nullglob +rm -f ./timestamp + # Run the application logic for i in $publisher; do for j in $subscriber; do publisher_name=$(basename "$i" _shape_main_linux) subscriber_name=$(basename "$j" _shape_main_linux) echo "Testing Publisher $publisher_name --- Subscriber $subscriber_name" + + # Generate fresh timestamp for this test run in %Y%m%d-%H_%M_%S format + # matching interoperability_report.py (e.g. 20260922-18_14_23) + if [[ -f "./timestamp" ]]; then + raw_ts=$(head -n 1 ./timestamp | tr -d '\r\n') + if [[ "$raw_ts" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})-(.*)$ ]]; then + pair_timestamp="${BASH_REMATCH[1]}${BASH_REMATCH[2]}${BASH_REMATCH[3]}-${BASH_REMATCH[4]}" + else + pair_timestamp="$raw_ts" + fi + else + pair_timestamp=$(date '+%Y%m%d-%H_%M_%S') + fi + + # Synchronize interoperability report filename and discovery report filename + if [[ -n $output ]]; then + interop_report_file="$output" + else + interop_report_file="${publisher_name}-${subscriber_name}-${pair_timestamp}.xml" + fi + + discovery_xml="junit_discovery_report_${pair_timestamp}.xml" + discovery_json="discovery_report_${pair_timestamp}.json" + extra_args="" if [[ "${subscriber_name,,}" == *opendds* && "${publisher_name,,}" == *connext_dds* ]]; then extra_args="--periodic-announcement 5000" fi; - if [[ -n $output ]]; then - python3 ./interoperability_report.py -P "$i" -S "$j" -o "$output" $extra_args - else - python3 ./interoperability_report.py -P "$i" -S "$j" $extra_args + if [[ -n $single_test ]]; then + extra_args="$extra_args -t $single_test" + elif [[ "$discovery_only" == "true" ]]; then + extra_args="$extra_args -t Test_Domain_0" + fi; + + sniffer_pid="" + + if [[ "$skip_discovery" != "true" ]]; then + dur=${sniffer_duration:-15} + if [[ "$discovery_only" == "true" ]]; then + dur=${sniffer_duration:-6} + fi + echo "Starting RTPS discovery sniffer for $publisher_name vs $subscriber_name (${dur}s)..." + python3 ./rtps_discovery_sniffer.py \ + -i any \ + -d "$dur" \ + -P "$publisher_name" \ + -S "$subscriber_name" \ + -o "$discovery_xml" \ + -j "$discovery_json" \ + -t "$pair_timestamp" & + sniffer_pid=$! + # Give sniffer half a second to initialize capture on interface + sleep 1 + fi + + python3 ./interoperability_report.py -P "$i" -S "$j" -o "$interop_report_file" $extra_args + + if [[ -n $sniffer_pid ]]; then + echo "Waiting for RTPS discovery sniffer to finish..." + wait "$sniffer_pid" 2>/dev/null || true fi + if [ -d "./OpenDDS-durable-data-dir" ]; then echo Deleting OpenDDS-durable-data-dir; rm -rf ./OpenDDS-durable-data-dir; diff --git a/run_tests_in_docker.sh b/run_tests_in_docker.sh new file mode 100755 index 0000000..f7689c4 --- /dev/null +++ b/run_tests_in_docker.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# ============================================================================== +# Run DDS-RTPS interoperability tests inside an isolated Docker container +# Workflow: +# 1. Archives any existing reports from host into archive_reports/ +# 2. Starts Docker container +# 3. Runs the test suite inside the container +# 4. Generates XML, XLSX, and HTML reports +# 5. Exits and cleans up Docker container automatically +# ============================================================================== +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +IMAGE_NAME="dds-rtps-tester" + +# Check if Docker is installed +if ! command -v docker &> /dev/null; then + echo "ERROR: 'docker' command not found. Please install Docker first." + exit 1 +fi + +# 1. Archive previous test reports from host if present +archive_dir="$SCRIPT_DIR/archive_reports" +shopt -s nullglob +old_reports=("$SCRIPT_DIR"/*.xml "$SCRIPT_DIR"/*.xlsx "$SCRIPT_DIR"/index.html "$SCRIPT_DIR"/discovery_report*.json "$SCRIPT_DIR"/discovery_summary.json "$SCRIPT_DIR"/timestamp) +if [ ${#old_reports[@]} -gt 0 ]; then + echo "==> [1/4] Archiving previous test reports to archive_reports/..." + mkdir -p "$archive_dir" + mv "${old_reports[@]}" "$archive_dir/" 2>/dev/null || true +fi +shopt -u nullglob +rm -f "$SCRIPT_DIR/timestamp" + +# 2. Build Docker image if not present or missing tshark +if [[ "$(docker images -q "$IMAGE_NAME" 2> /dev/null)" == "" ]] || ! docker run --rm "$IMAGE_NAME" which tshark &> /dev/null; then + echo "==> [2/4] Building Docker image: $IMAGE_NAME..." + docker build -t "$IMAGE_NAME" . +fi + +# Determine test arguments: default to all executables under ./executables +if [ $# -eq 0 ]; then + TEST_CMD="./run_tests.sh -i ./executables" +else + TEST_CMD="./run_tests.sh $*" +fi + +echo "==> [3/4] Running tests inside Docker container ($IMAGE_NAME)..." +echo " Command: $TEST_CMD" + +# 3. Run container, execute tests, generate reports, and automatically terminate container (--rm) +# --cap-add=NET_ADMIN and NET_RAW allow tshark to capture discovery traffic inside the container +docker run --rm \ + --cap-add=NET_ADMIN \ + --cap-add=NET_RAW \ + --user "$(id -u):$(id -g)" \ + -e PYTHONDONTWRITEBYTECODE=1 \ + -v "$SCRIPT_DIR:/workspace" \ + -w /workspace \ + "$IMAGE_NAME" \ + /bin/bash -c "$TEST_CMD; ./generate_reports.sh" + +echo "" +echo "==> [4/4] Docker container finished and closed." +echo "==> Generated reports in $SCRIPT_DIR:" +ls -lh "$SCRIPT_DIR"/junit_interoperability_report.xml "$SCRIPT_DIR"/junit_discovery_report*.xml "$SCRIPT_DIR"/discovery_report*.json "$SCRIPT_DIR"/interoperability_report.xlsx "$SCRIPT_DIR"/index.html 2>/dev/null || true diff --git a/srcCxx/shape_configurator_eprosima_fast_dds.h b/srcCxx/shape_configurator_eprosima_fast_dds.h index e0cf0d1..d50f03b 100644 --- a/srcCxx/shape_configurator_eprosima_fast_dds.h +++ b/srcCxx/shape_configurator_eprosima_fast_dds.h @@ -29,9 +29,51 @@ #define ShapeTypeDataWriter DataWriter #define StringSeq std::vector +#define DDS_VENDOR_NAME "eProsima Fast DDS" + namespace DDS = eprosima::fastdds::dds; #define RETCODE_OK DDS::RETCODE_OK +template +bool vendor_check_publisher_options(const OptionsType *options, std::string &error_msg) +{ + if (options->ordered_access_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Ordered Access is not supported: Fast DDS does not implement ordered sample delivery."; + return false; + } + if (options->coherent_set_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Coherent Access is not supported: Fast DDS does not implement coherent changes across readers."; + return false; + } + if (options->coherent_set_access_scope_set && options->coherent_set_access_scope != DDS::INSTANCE_PRESENTATION_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Access Scope is not supported: Fast DDS only supports default INSTANCE_PRESENTATION_QOS."; + return false; + } + return true; +} + +template +bool vendor_check_subscriber_options(const OptionsType *options, std::string &error_msg) +{ + if (options->ordered_access_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Ordered Access is not supported: Fast DDS does not implement ordered sample delivery."; + return false; + } + if (options->coherent_set_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Coherent Access is not supported: Fast DDS does not implement coherent changes across readers."; + return false; + } + if (options->coherent_set_access_scope_set && options->coherent_set_access_scope != DDS::INSTANCE_PRESENTATION_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Access Scope is not supported: Fast DDS only supports default INSTANCE_PRESENTATION_QOS."; + return false; + } + if (options->timebasedfilter_interval_us > 0) { + error_msg = "[" DDS_VENDOR_NAME "] TimeBasedFilter QoS is not supported: Fast DDS does not implement minimum_separation sample filtering on DataReader."; + return false; + } + return true; +} + void configure_fastdds_dp_qos(DDS::DomainParticipantQos &dp_qos) { dp_qos.properties().properties().emplace_back( diff --git a/srcCxx/shape_configurator_intercom_dds.h b/srcCxx/shape_configurator_intercom_dds.h index e5eb918..cd06725 100644 --- a/srcCxx/shape_configurator_intercom_dds.h +++ b/srcCxx/shape_configurator_intercom_dds.h @@ -16,6 +16,36 @@ #define DDS_BOOLEAN_TRUE true #define DDS_BOOLEAN_FALSE false +#define DDS_VENDOR_NAME "Kongsberg InterCOM DDS" + +template +bool vendor_check_publisher_options(const OptionsType *options, std::string &error_msg) +{ + if (options->coherent_set_access_scope_set && options->coherent_set_access_scope >= DDS::GROUP_PRESENTATION_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Access Scope GROUP_PRESENTATION_QOS is not supported: InterCOM DDS only supports INSTANCE_PRESENTATION_QOS."; + return false; + } + if (options->coherent_set_enabled && options->coherent_set_access_scope >= DDS::TOPIC_PRESENTATION_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Coherent Access with Presentation Access Scope TOPIC_PRESENTATION_QOS or higher is not supported: InterCOM DDS only supports INSTANCE_PRESENTATION_QOS for coherent access."; + return false; + } + return true; +} + +template +bool vendor_check_subscriber_options(const OptionsType *options, std::string &error_msg) +{ + if (options->coherent_set_access_scope_set && options->coherent_set_access_scope >= DDS::GROUP_PRESENTATION_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Access Scope GROUP_PRESENTATION_QOS is not supported: InterCOM DDS only supports INSTANCE_PRESENTATION_QOS."; + return false; + } + if (options->coherent_set_enabled && options->coherent_set_access_scope >= DDS::TOPIC_PRESENTATION_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Coherent Access with Presentation Access Scope TOPIC_PRESENTATION_QOS or higher is not supported: InterCOM DDS only supports INSTANCE_PRESENTATION_QOS for coherent access."; + return false; + } + return true; +} + inline const char *get_qos_policy_name(DDS::QosPolicyId_t policy_id) { switch (policy_id) { case DDS::USERDATA_QOS_POLICY_ID: diff --git a/srcCxx/shape_configurator_opendds.h b/srcCxx/shape_configurator_opendds.h index 67fdf9e..f73cdf4 100644 --- a/srcCxx/shape_configurator_opendds.h +++ b/srcCxx/shape_configurator_opendds.h @@ -17,6 +17,43 @@ #define STRING_IN .in() #define STRING_INOUT .inout() #define STRING_ALLOC(LHS, RHS) LHS = CORBA::string_alloc(RHS) +#define DDS_VENDOR_NAME "OpenDDS" + +template +bool vendor_check_publisher_options(const OptionsType *options, std::string &error_msg) +{ + if (options->ordered_access_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Ordered Access is not supported in this test configuration."; + return false; + } + if (options->coherent_set_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Coherent Access is not supported in this test configuration."; + return false; + } + if (options->coherent_set_access_scope_set && options->coherent_set_access_scope != DDS::INSTANCE_PRESENTATION_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Access Scope is not supported in this test configuration: only default INSTANCE_PRESENTATION_QOS is supported."; + return false; + } + return true; +} + +template +bool vendor_check_subscriber_options(const OptionsType *options, std::string &error_msg) +{ + if (options->ordered_access_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Ordered Access is not supported in this test configuration."; + return false; + } + if (options->coherent_set_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Coherent Access is not supported in this test configuration."; + return false; + } + if (options->coherent_set_access_scope_set && options->coherent_set_access_scope != DDS::INSTANCE_PRESENTATION_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Access Scope is not supported in this test configuration: only default INSTANCE_PRESENTATION_QOS is supported."; + return false; + } + return true; +} const char* get_qos_policy_name(DDS::QosPolicyId_t policy_id) { diff --git a/srcCxx/shape_configurator_rti_connext_dds.h b/srcCxx/shape_configurator_rti_connext_dds.h index 92ba205..56fb923 100644 --- a/srcCxx/shape_configurator_rti_connext_dds.h +++ b/srcCxx/shape_configurator_rti_connext_dds.h @@ -6,6 +6,24 @@ #define LISTENER_STATUS_MASK_ALL (DDS_STATUS_MASK_ALL) +#define DDS_VENDOR_NAME "RTI Connext DDS" + +template +bool vendor_check_publisher_options(const OptionsType *options, std::string &error_msg) +{ + (void)options; + (void)error_msg; + return true; +} + +template +bool vendor_check_subscriber_options(const OptionsType *options, std::string &error_msg) +{ + (void)options; + (void)error_msg; + return true; +} + void StringSeq_push(DDS::StringSeq &string_seq, const char *elem) { string_seq.ensure_length(string_seq.length()+1, string_seq.length()+1); diff --git a/srcCxx/shape_configurator_rti_connext_micro.h b/srcCxx/shape_configurator_rti_connext_micro.h index 138835b..a60d8c9 100644 --- a/srcCxx/shape_configurator_rti_connext_micro.h +++ b/srcCxx/shape_configurator_rti_connext_micro.h @@ -49,6 +49,72 @@ void StringSeq_push(StringSeq &string_seq, const char *elem) } +#define DDS_VENDOR_NAME "RTI Connext Micro" + +template +bool vendor_check_publisher_options(const OptionsType *options, std::string &error_msg) +{ + if (options->ordered_access_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Ordered Access is not supported by RTI Connext Micro."; + return false; + } + if (options->coherent_set_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Coherent Access is not supported by RTI Connext Micro."; + return false; + } + if (options->coherent_set_access_scope_set && options->coherent_set_access_scope != DDS_INSTANCE_PRESENTATION_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Access Scope is not supported by RTI Connext Micro: only default INSTANCE_PRESENTATION_QOS is supported."; + return false; + } + if (options->lifespan_us > 0) { + error_msg = "[" DDS_VENDOR_NAME "] Lifespan QoS is not supported by RTI Connext Micro."; + return false; + } + if (options->durability_kind == TRANSIENT_DURABILITY_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Durability = TRANSIENT_DURABILITY_QOS is not supported: RTI Connext Micro only supports VOLATILE and TRANSIENT_LOCAL."; + return false; + } + if (options->durability_kind == PERSISTENT_DURABILITY_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Durability = PERSISTENT_DURABILITY_QOS is not supported: RTI Connext Micro only supports VOLATILE and TRANSIENT_LOCAL."; + return false; + } + return true; +} + +template +bool vendor_check_subscriber_options(const OptionsType *options, std::string &error_msg) +{ + if (options->ordered_access_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Ordered Access is not supported by RTI Connext Micro."; + return false; + } + if (options->coherent_set_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Coherent Access is not supported by RTI Connext Micro."; + return false; + } + if (options->coherent_set_access_scope_set && options->coherent_set_access_scope != DDS_INSTANCE_PRESENTATION_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Access Scope is not supported by RTI Connext Micro: only default INSTANCE_PRESENTATION_QOS is supported."; + return false; + } + if (options->timebasedfilter_interval_us > 0) { + error_msg = "[" DDS_VENDOR_NAME "] TimeBasedFilter QoS is not supported by RTI Connext Micro."; + return false; + } + if (options->durability_kind == TRANSIENT_DURABILITY_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Durability = TRANSIENT_DURABILITY_QOS is not supported: RTI Connext Micro only supports VOLATILE and TRANSIENT_LOCAL."; + return false; + } + if (options->durability_kind == PERSISTENT_DURABILITY_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Durability = PERSISTENT_DURABILITY_QOS is not supported: RTI Connext Micro only supports VOLATILE and TRANSIENT_LOCAL."; + return false; + } + if (options->cft_expression != NULL) { + error_msg = "[" DDS_VENDOR_NAME "] ContentFilteredTopic is not supported by RTI Connext Micro."; + return false; + } + return true; +} + const char* get_qos_policy_name(DDS::QosPolicyId_t policy_id) { //case DDS::USERDATA_QOS_POLICY_ID) { return "USERDATA"; diff --git a/srcCxx/shape_configurator_toc_coredx_dds.h b/srcCxx/shape_configurator_toc_coredx_dds.h index a3ca320..3f14b8e 100644 --- a/srcCxx/shape_configurator_toc_coredx_dds.h +++ b/srcCxx/shape_configurator_toc_coredx_dds.h @@ -16,6 +16,24 @@ #define DDS_BOOLEAN_TRUE (1) #define DDS_BOOLEAN_FALSE (0) +#define DDS_VENDOR_NAME "Twin Oaks CoreDX" + +template +bool vendor_check_publisher_options(const OptionsType *options, std::string &error_msg) +{ + (void)options; + (void)error_msg; + return true; +} + +template +bool vendor_check_subscriber_options(const OptionsType *options, std::string &error_msg) +{ + (void)options; + (void)error_msg; + return true; +} + void StringSeq_push(DDS::StringSeq &string_seq, const char *elem) { char * e = NULL; diff --git a/srcCxx/shape_main.cxx b/srcCxx/shape_main.cxx index 969b4d0..1b2979b 100644 --- a/srcCxx/shape_main.cxx +++ b/srcCxx/shape_main.cxx @@ -492,16 +492,18 @@ class ShapeOptions { } #if defined(RTI_CONNEXT_MICRO) - if (subscribe && (color != NULL || cft_expression != NULL)) { + if (subscribe && cft_expression != NULL) { + logger.log_message("[RTI Connext Micro] ContentFilteredTopic (--cft) is not supported by RTI Connext Micro.", Verbosity::ERROR); + return false; + } + if (subscribe && color != NULL) { STRING_FREE(color); color = NULL; - STRING_FREE(cft_expression); - cft_expression = NULL; - logger.log_message("warning: content filtered topic not supported, normal topic used", Verbosity::ERROR); + logger.log_message("warning: content filtered topic not available in Micro, normal topic used", Verbosity::DEBUG); } if (subscribe && take_read_next_instance) { take_read_next_instance = false; - logger.log_message("warning: use of take/read_next_instance() not available, using take/read()", Verbosity::ERROR); + logger.log_message("warning: use of take/read_next_instance() not available, using take/read()", Verbosity::DEBUG); } #endif @@ -1156,7 +1158,7 @@ class ShapeApplication { // Initialize entities array topics = (Topic**) malloc(sizeof(Topic*) * options->num_topics); if (topics == NULL) { - logger.log_message("Error allocating memory for topics", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Error allocating memory for topics (" + std::to_string(options->num_topics) + " requested)", Verbosity::ERROR); return false; } for (unsigned int i = 0; i < options->num_topics; ++i) { @@ -1166,7 +1168,7 @@ class ShapeApplication { if (options->publish) { dws = (ShapeTypeDataWriter**) malloc(sizeof(ShapeTypeDataWriter*) * options->num_topics); if (dws == NULL) { - logger.log_message("Error allocating memory for DataWriters", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Error allocating memory for DataWriters (" + std::to_string(options->num_topics) + " requested)", Verbosity::ERROR); return false; } for (unsigned int i = 0; i < options->num_topics; ++i) { @@ -1175,7 +1177,7 @@ class ShapeApplication { } else { drs = (ShapeTypeDataReader**) malloc(sizeof(ShapeTypeDataReader*) * options->num_topics); if (drs == NULL) { - logger.log_message("Error allocating memory for DataReaders", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Error allocating memory for DataReaders (" + std::to_string(options->num_topics) + " requested)", Verbosity::ERROR); return false; } for (unsigned int i = 0; i < options->num_topics; ++i) { @@ -1191,7 +1193,7 @@ class ShapeApplication { DomainParticipantFactory *dpf = OBTAIN_DOMAIN_PARTICIPANT_FACTORY; if (dpf == NULL) { - logger.log_message("failed to create participant factory (missing license?).", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Failed to create participant factory (missing license or initialization failed).", Verbosity::ERROR); return false; } logger.log_message("Participant Factory created", Verbosity::DEBUG); @@ -1201,7 +1203,7 @@ class ShapeApplication { #ifdef RTI_CONNEXT_MICRO if (!config_micro()) { - logger.log_message("Error configuring Connext Micro", Verbosity::ERROR); + logger.log_message("[RTI Connext Micro] Failed to configure Connext Micro registry components (wh, rh, dpde, or udp)", Verbosity::ERROR); return false; } #endif @@ -1232,7 +1234,7 @@ class ShapeApplication { #endif if (!result) { - logger.log_message("Error configuring Data Fragmentation Size = " + logger.log_message("[" DDS_VENDOR_NAME "] Error configuring Data Fragmentation Size = " + std::to_string(options->datafrag_size), Verbosity::ERROR); return false; } else { @@ -1243,7 +1245,7 @@ class ShapeApplication { dp = dpf->create_participant( options->domain_id, dp_qos, &dp_listener, LISTENER_STATUS_MASK_ALL ); if (dp == NULL) { - logger.log_message("failed to create participant (missing license?).", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Failed to create participant on domain " + std::to_string(options->domain_id) + " (missing license or invalid QoS).", Verbosity::ERROR); return false; } logger.log_message("Participant created", Verbosity::DEBUG); @@ -1262,7 +1264,7 @@ class ShapeApplication { printf("Create topic: %s\n", topic_name.c_str()); topics[i] = dp->create_topic( topic_name.c_str(), "ShapeType", TOPIC_QOS_DEFAULT, NULL, LISTENER_STATUS_MASK_NONE); if (topics[i] == NULL) { - logger.log_message("failed to create topic <" + topic_name + ">", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Failed to create topic <" + topic_name + ">", Verbosity::ERROR); return false; } } @@ -1292,6 +1294,7 @@ class ShapeApplication { return run_subscriber(options); } + logger.log_message("[" DDS_VENDOR_NAME "] Neither Publisher nor Subscriber was initialized before run()", Verbosity::ERROR); return false; } @@ -1299,6 +1302,12 @@ class ShapeApplication { bool init_publisher(ShapeOptions *options) { logger.log_message("Running init_publisher() function", Verbosity::DEBUG); + std::string vendor_error; + if (!vendor_check_publisher_options(options, vendor_error)) { + logger.log_message(vendor_error, Verbosity::ERROR); + return false; + } + PublisherQos pub_qos; DataWriterQos dw_qos; @@ -1318,22 +1327,6 @@ class ShapeApplication { } if (options->ordered_access_enabled || options->coherent_set_enabled) { pub_qos.presentation.access_scope = options->coherent_set_access_scope; - #if defined(INTERCOM_DDS) - if ( pub_qos.presentation.access_scope >= GROUP_PRESENTATION_QOS ) - { - logger.log_message(" Presentation Access Scope " - + QosUtils::to_string(pub_qos.presentation.access_scope) - + std::string(" : not supported"), Verbosity::ERROR); - return false; - } - if (pub_qos.presentation.coherent_access && pub_qos.presentation.access_scope >= TOPIC_PRESENTATION_QOS) - { - logger.log_message(" Coherent Access with Presentation Access Scope " - + QosUtils::to_string(pub_qos.presentation.access_scope) - + std::string(" : not supported"), Verbosity::ERROR); - return false; - } - #endif } logger.log_message(" Presentation Coherent Access = " + std::string(pub_qos.presentation.coherent_access ? "true" : "false"), Verbosity::DEBUG); @@ -1343,23 +1336,23 @@ class ShapeApplication { QosUtils::to_string(pub_qos.presentation.access_scope), Verbosity::DEBUG); #else if (options->coherent_set_enabled) { - logger.log_message(" Presentation Coherent Access = not supported", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Presentation Coherent Access is not supported", Verbosity::ERROR); return false; } if (options->ordered_access_enabled) { - logger.log_message(" Presentation Ordered Access = not supported", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Presentation Ordered Access is not supported", Verbosity::ERROR); return false; } if (options->coherent_set_access_scope_set && options->coherent_set_access_scope != INSTANCE_PRESENTATION_QOS) { - logger.log_message(" Presentation Access Scope = not supported", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Presentation Access Scope " + QosUtils::to_string(options->coherent_set_access_scope) + " is not supported", Verbosity::ERROR); return false; } #endif pub = dp->create_publisher(pub_qos, NULL, LISTENER_STATUS_MASK_NONE); if (pub == NULL) { - logger.log_message("failed to create publisher", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Failed to create publisher (incompatible QoS or resource limit)", Verbosity::ERROR); return false; } logger.log_message("Publisher created", Verbosity::DEBUG); @@ -1375,10 +1368,10 @@ class ShapeApplication { dw_qos.durability FIELD_ACCESSOR.kind = options->durability_kind; #if defined(RTI_CONNEXT_MICRO) if (dw_qos.durability FIELD_ACCESSOR.kind == TRANSIENT_DURABILITY_QOS) { - logger.log_message(" Durability = TRANSIENT_DURABILITY_QOS : not supported", Verbosity::ERROR); + logger.log_message("[RTI Connext Micro] Durability = TRANSIENT_DURABILITY_QOS is not supported", Verbosity::ERROR); return false; } else if (dw_qos.durability FIELD_ACCESSOR.kind == PERSISTENT_DURABILITY_QOS) { - logger.log_message(" Durability = PERSISTENT_DURABILITY_QOS : not supported", Verbosity::ERROR); + logger.log_message("[RTI Connext Micro] Durability = PERSISTENT_DURABILITY_QOS is not supported", Verbosity::ERROR); return false; } #endif @@ -1451,13 +1444,11 @@ class ShapeApplication { if (options->lifespan_us > 0) { #if defined (RTI_CONNEXT_MICRO) - logger.log_message(" Lifespan = not supported", Verbosity::ERROR); + logger.log_message("[RTI Connext Micro] Lifespan QoS is not supported", Verbosity::ERROR); return false; -#elif defined(RTI_CONNEXT_DDS) || defined(OPENDDS) || defined(TWINOAKS_COREDX) || defined(INTERCOM_DDS) +#else dw_qos.lifespan FIELD_ACCESSOR.duration.SECONDS_FIELD_NAME = options->lifespan_us / 1000000; dw_qos.lifespan FIELD_ACCESSOR.duration.nanosec = (options->lifespan_us % 1000000) * 1000; -#elif defined(EPROSIMA_FAST_DDS) - dw_qos.lifespan FIELD_ACCESSOR.duration = Duration_t(options->lifespan_us * 1e-6); #endif } #if !defined(RTI_CONNEXT_MICRO) @@ -1490,7 +1481,7 @@ class ShapeApplication { printf("Create writer for topic: %s color: %s\n", topics[i]->get_name() NAME_ACCESSOR, options->color ); dws[i] = dynamic_cast(pub->create_datawriter( topics[i], dw_qos, NULL, LISTENER_STATUS_MASK_NONE)); if (dws[i] == NULL) { - logger.log_message("failed to create datawriter[" + std::to_string(i) + "] topic: " + topics[i]->get_name(), Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Failed to create datawriter[" + std::to_string(i) + "] for topic: " + topics[i]->get_name() NAME_ACCESSOR + " (check QoS compatibility or resource limits)", Verbosity::ERROR); return false; } } @@ -1521,6 +1512,12 @@ class ShapeApplication { bool init_subscriber(ShapeOptions *options) { logger.log_message("Running init_subscriber() function", Verbosity::DEBUG); + std::string vendor_error; + if (!vendor_check_subscriber_options(options, vendor_error)) { + logger.log_message(vendor_error, Verbosity::ERROR); + return false; + } + SubscriberQos sub_qos; DataReaderQos dr_qos; @@ -1540,22 +1537,6 @@ class ShapeApplication { } if (options->ordered_access_enabled || options->coherent_set_enabled) { sub_qos.presentation.access_scope = options->coherent_set_access_scope; - #if defined(INTERCOM_DDS) - if ( sub_qos.presentation.access_scope >= GROUP_PRESENTATION_QOS ) - { - logger.log_message(" Presentation Access Scope " - + QosUtils::to_string(sub_qos.presentation.access_scope) - + std::string(" : not supported"), Verbosity::ERROR); - return false; - } - if (sub_qos.presentation.coherent_access && sub_qos.presentation.access_scope >= TOPIC_PRESENTATION_QOS) - { - logger.log_message(" Coherent Access with Presentation Access Scope " - + QosUtils::to_string(sub_qos.presentation.access_scope) - + std::string(" : not supported"), Verbosity::ERROR); - return false; - } - #endif } logger.log_message(" Presentation Coherent Access = " + @@ -1567,23 +1548,23 @@ class ShapeApplication { #else if (options->coherent_set_enabled) { - logger.log_message(" Presentation Coherent Access = not supported", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Presentation Coherent Access is not supported", Verbosity::ERROR); return false; } if (options->ordered_access_enabled) { - logger.log_message(" Presentation Ordered Access = not supported", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Presentation Ordered Access is not supported", Verbosity::ERROR); return false; } if (options->coherent_set_access_scope_set && (options->coherent_set_access_scope != INSTANCE_PRESENTATION_QOS)) { - logger.log_message(" Presentation Access Scope = not supported", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Presentation Access Scope " + QosUtils::to_string(options->coherent_set_access_scope) + " is not supported", Verbosity::ERROR); return false; } #endif sub = dp->create_subscriber( sub_qos, NULL, LISTENER_STATUS_MASK_NONE ); if (sub == NULL) { - logger.log_message("failed to create subscriber", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Failed to create subscriber (incompatible QoS or resource limit)", Verbosity::ERROR); return false; } @@ -1600,10 +1581,10 @@ class ShapeApplication { dr_qos.durability FIELD_ACCESSOR.kind = options->durability_kind; #if defined(RTI_CONNEXT_MICRO) if (dr_qos.durability FIELD_ACCESSOR.kind == TRANSIENT_DURABILITY_QOS) { - logger.log_message(" Durability = TRANSIENT_DURABILITY_QOS : not supported", Verbosity::ERROR); + logger.log_message("[RTI Connext Micro] Durability = TRANSIENT_DURABILITY_QOS is not supported", Verbosity::ERROR); return false; } else if (dr_qos.durability FIELD_ACCESSOR.kind == PERSISTENT_DURABILITY_QOS) { - logger.log_message(" Durability = PERSISTENT_DURABILITY_QOS : not supported", Verbosity::ERROR); + logger.log_message("[RTI Connext Micro] Durability = PERSISTENT_DURABILITY_QOS is not supported", Verbosity::ERROR); return false; } #endif @@ -1646,7 +1627,7 @@ class ShapeApplication { if ( options->timebasedfilter_interval_us > 0) { #if defined(EPROSIMA_FAST_DDS) || defined(RTI_CONNEXT_MICRO) - logger.log_message(" TimeBasedFilter = not supported", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] TimeBasedFilter QoS is not supported", Verbosity::ERROR); return false; #else dr_qos.time_based_filter FIELD_ACCESSOR.minimum_separation.SECONDS_FIELD_NAME = options->timebasedfilter_interval_us / 1000000; @@ -1735,14 +1716,14 @@ class ShapeApplication { } if (cft == NULL) { - logger.log_message("failed to create content filtered topic", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Failed to create content filtered topic <" + filtered_topic_name_str + "> with expression: " + (filter_expr ? filter_expr : "color"), Verbosity::ERROR); return false; } printf("Create reader for topic: %s\n", cft->get_name() NAME_ACCESSOR); drs[i] = dynamic_cast(sub->create_datareader(cft, dr_qos, NULL, LISTENER_STATUS_MASK_NONE)); if (drs[i] == NULL) { - logger.log_message("failed to create datareader[" + std::to_string(i) + "] topic: " + topics[i]->get_name(), Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Failed to create datareader[" + std::to_string(i) + "] for filtered topic: " + cft->get_name() NAME_ACCESSOR + " (check QoS compatibility or resource limits)", Verbosity::ERROR); return false; } } @@ -1755,7 +1736,7 @@ class ShapeApplication { printf("Create reader for topic: %s\n", topics[i]->get_name() NAME_ACCESSOR); drs[i] = dynamic_cast(sub->create_datareader(topics[i], dr_qos, NULL, LISTENER_STATUS_MASK_NONE)); if (drs[i] == NULL) { - logger.log_message("failed to create datareader[" + std::to_string(i) + "] topic: " + topics[i]->get_name(), Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Failed to create datareader[" + std::to_string(i) + "] for topic: " + topics[i]->get_name() NAME_ACCESSOR + " (check QoS compatibility or resource limits)", Verbosity::ERROR); return false; } } @@ -1808,7 +1789,7 @@ class ShapeApplication { // Create a previous_handle per topic previous_handles = (InstanceHandle_t*) malloc(sizeof(InstanceHandle_t) * options->num_topics); if (previous_handles == NULL) { - logger.log_message("Error allocating memory for previous_handles", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Error allocating memory for previous_handles (" + std::to_string(options->num_topics) + " requested)", Verbosity::ERROR); return false; } #if defined(EPROSIMA_FAST_DDS) diff --git a/test_rtps_discovery_sniffer.py b/test_rtps_discovery_sniffer.py new file mode 100644 index 0000000..912ee53 --- /dev/null +++ b/test_rtps_discovery_sniffer.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +Unit tests for rtps_discovery_sniffer.py +Tests decoding functions, parsing of discovery traffic, and JUnit XML report generation. +""" + +import json +import os +import tempfile +import unittest +from pathlib import Path + +import junitparser +import rtps_discovery_sniffer as sniffer + + +class TestRTPSDiscoverySniffer(unittest.TestCase): + + def test_vendor_decoding(self): + self.assertEqual(sniffer.decode_vendor("010f"), ("01.0f", "eProsima Fast DDS")) + self.assertEqual(sniffer.decode_vendor("0110"), ("01.10", "Eclipse Cyclone DDS")) + self.assertEqual(sniffer.decode_vendor("0101"), ("01.01", "RTI Connext DDS")) + self.assertEqual(sniffer.decode_vendor("0105"), ("01.05", "Twin Oaks CoreDX DDS")) + self.assertEqual(sniffer.decode_vendor("0114"), ("01.14", "Dust DDS")) + self.assertEqual(sniffer.decode_vendor("0103"), ("01.03", "OpenDDS")) + self.assertEqual(sniffer.decode_vendor("0x010f"), ("01.0f", "eProsima Fast DDS")) + + def test_version_decoding(self): + self.assertEqual(sniffer.decode_version("0x0204"), "2.4") + self.assertEqual(sniffer.decode_version("0x0202"), "2.2") + self.assertEqual(sniffer.decode_version("0x0201"), "2.1") + self.assertEqual(sniffer.decode_version(0x0204), "2.4") + + def test_builtin_endpoints_decoding(self): + mask_hex, flags = sniffer.decode_builtin_endpoints("0x0000003f") + self.assertEqual(mask_hex, "0x0000003f") + self.assertIn("DISC_BUILTIN_ENDPOINT_PARTICIPANT_ANNOUNCER", flags) + self.assertIn("DISC_BUILTIN_ENDPOINT_PARTICIPANT_DETECTOR", flags) + self.assertIn("DISC_BUILTIN_ENDPOINT_PUBLICATION_ANNOUNCER", flags) + self.assertIn("DISC_BUILTIN_ENDPOINT_PUBLICATION_DETECTOR", flags) + self.assertIn("DISC_BUILTIN_ENDPOINT_SUBSCRIPTION_ANNOUNCER", flags) + self.assertIn("DISC_BUILTIN_ENDPOINT_SUBSCRIPTION_DETECTOR", flags) + + def test_timestamp_selection(self): + # 1. Without timestamp file + ts1 = sniffer.get_current_timestamp() + self.assertRegex(ts1, r"^\d{8}-\d{2}_\d{2}_\d{2}$") + + # 2. With timestamp file + ts_file = Path("timestamp") + was_present = ts_file.exists() + original_content = ts_file.read_text() if was_present else None + try: + ts_file.write_text("20260916-20_09_43\n") + self.assertEqual(sniffer.get_current_timestamp(), "20260916-20_09_43") + # Also test normalization from YYYY-MM-DD + ts_file.write_text("2026-09-16-20_09_43\n") + self.assertEqual(sniffer.get_current_timestamp(), "20260916-20_09_43") + finally: + if was_present and original_content is not None: + ts_file.write_text(original_content) + elif ts_file.exists(): + ts_file.unlink() + + def test_junit_report_generation(self): + with tempfile.TemporaryDirectory() as tmpdir: + out_xml = os.path.join(tmpdir, "junit_discovery_report_test.xml") + sample_summary = { + "metadata": {"total_rtps_packets": 20}, + "participants": { + "010ff34897b8c87f00000000": { + "guid_prefix": "010ff34897b8c87f00000000", + "src_ip": "192.168.1.10", + "vendor_id": "01.0f", + "vendor_name": "eProsima Fast DDS", + "protocol_version": "2.2", + "lease_duration_sec": 20.0, + "builtin_endpoints_mask": "0x00000c3f", + "builtin_endpoints_flags": ["DISC_BUILTIN_ENDPOINT_PARTICIPANT_ANNOUNCER"], + "locators": [{"type": "unicast", "ip": "192.168.1.10", "port": 7410}], + }, + "0110f34897b8c87f00000000": { + "guid_prefix": "0110f34897b8c87f00000000", + "src_ip": "192.168.1.20", + "vendor_id": "01.10", + "vendor_name": "Eclipse Cyclone DDS", + "protocol_version": "2.3", + "lease_duration_sec": 20.0, + "builtin_endpoints_mask": "0x00000c3f", + "builtin_endpoints_flags": ["DISC_BUILTIN_ENDPOINT_PARTICIPANT_ANNOUNCER"], + "locators": [{"type": "unicast", "ip": "192.168.1.20", "port": 7412}], + }, + }, + "endpoints": [ + { + "endpoint_guid": "010ff34897b8c87f0000000000000102", + "entity_kind": "DataWriter", + "topic_name": "Square", + "type_name": "ShapeType", + "reliability": "RELIABLE", + "durability": "VOLATILE", + "ownership": "SHARED", + "src_ip": "192.168.1.10", + }, + { + "endpoint_guid": "0110f34897b8c87f0000000000000107", + "entity_kind": "DataReader", + "topic_name": "Square", + "type_name": "ShapeType", + "reliability": "RELIABLE", + "durability": "VOLATILE", + "ownership": "SHARED", + "src_ip": "192.168.1.20", + }, + ], + } + + xml = sniffer.generate_junit_report( + summary=sample_summary, + output_xml_path=out_xml, + publisher_name="fastdds", + subscriber_name="cyclone", + expected_participants=2, + expected_topic="Square", + expected_type="ShapeType", + ) + + self.assertTrue(os.path.exists(out_xml)) + read_xml = junitparser.JUnitXml.fromfile(out_xml) + suites = list(read_xml) + self.assertEqual(len(suites), 1) + suite = suites[0] + self.assertEqual(suite.name, "rtps_discovery_fastdds---cyclone") + self.assertEqual(suite.tests, 7) + self.assertEqual(suite.failures, 0) + self.assertEqual(suite.errors, 0) + + def test_qos_incompatibility_failure(self): + with tempfile.TemporaryDirectory() as tmpdir: + out_xml = os.path.join(tmpdir, "junit_discovery_report_fail.xml") + sample_summary = { + "metadata": {"total_rtps_packets": 20}, + "participants": { + "010ff34897b8c87f00000000": { + "guid_prefix": "010ff34897b8c87f00000000", + "vendor_id": "01.0f", + "vendor_name": "eProsima Fast DDS", + "protocol_version": "2.2", + }, + "0110f34897b8c87f00000000": { + "guid_prefix": "0110f34897b8c87f00000000", + "vendor_id": "01.10", + "vendor_name": "Eclipse Cyclone DDS", + "protocol_version": "2.3", + }, + }, + "endpoints": [ + { + "endpoint_guid": "010ff34897b8c87f0000000000000102", + "entity_kind": "DataWriter", + "topic_name": "Square", + "type_name": "ShapeType", + "reliability": "BEST_EFFORT", + "durability": "VOLATILE", + }, + { + "endpoint_guid": "0110f34897b8c87f0000000000000107", + "entity_kind": "DataReader", + "topic_name": "Square", + "type_name": "ShapeType", + "reliability": "RELIABLE", # Incompatible with BEST_EFFORT writer! + "durability": "VOLATILE", + }, + ], + } + + sniffer.generate_junit_report( + summary=sample_summary, + output_xml_path=out_xml, + ) + + read_xml = junitparser.JUnitXml.fromfile(out_xml) + suite = list(read_xml)[0] + self.assertEqual(suite.failures, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/update_readme.md b/update_readme.md new file mode 100644 index 0000000..07ebb36 --- /dev/null +++ b/update_readme.md @@ -0,0 +1,130 @@ +# Report of Changes: Enhanced Error Diagnostics & Vendor Compatibility Handling + +## 1. Executive Summary + +This update resolves issues where GitHub Actions interoperability test runs frequently reported generic `ERROR` statuses or produced incorrect test classifications across different DDS implementations. + +The primary goals accomplished are: +1. **Elimination of Bare/Generic `ERROR` Messages**: Replaced all uninformative error logs in `srcCxx/shape_main.cxx` with descriptive, context-rich error messages that explicitly state the DDS vendor name, the QoS policy or feature involved, and the specific failure reason. +2. **Early Vendor-Specific Capability Validation**: Introduced vendor-specific check functions in each `shape_configurator_*.h` header to validate command-line options against each vendor's capabilities prior to DDS entity creation. +3. **Resolution of Feature Incompatibilities & Bugs**: + - Fixed the eProsima Fast DDS Lifespan calculation bug where duration was truncated to 0. + - Documented and handled `OrderedAccess` and `CoherentSets` restrictions (unsupported by Fast DDS and OpenDDS in this test configuration). + - Handled `TimeBasedFilter` restrictions (unsupported on Fast DDS and RTI Connext Micro). + - Handled `Presentation QoS` access scope restrictions on Kongsberg InterCOM DDS. + - Fixed RTI Connext Micro command-line parsing to avoid false rejections of `-c `. +4. **Distinction Between Failures and Unsupported Features in Test Runner**: + - `interoperability_report.py` now distinguishes real test failures (`FAILED`) from features unsupported by a vendor (`UNSUPPORTED`). + - Unsupported tests now extract and print the concrete reason directly to the console and mark the test case as `junitparser.Skipped` (preventing false GitHub Actions test suite failures while retaining full compatibility with `generate_xlsx_report.py`). + +--- + +## 2. Problem Analysis & Specific Issues Addressed + +### Issue 1: `OrderedAccess` and `CoherentSets` Support +- **Problem**: Only RTI Connext DDS, Twin Oaks CoreDX, and Kongsberg InterCOM DDS support presentation ordered access and coherent sets. eProsima Fast DDS and OpenDDS do not support them in this configuration. Previously, attempting to run these tests resulted in generic `READER_NOT_MATCHED` or `SUB_UNSUPPORTED_FEATURE` errors without actionable explanations. +- **Solution**: Centralized capability checks in `shape_configurator_eprosima_fast_dds.h` and `shape_configurator_opendds.h` that reject these options immediately with explicit messages (e.g., `"[eProsima Fast DDS] Presentation QoS Ordered Access is not supported by eProsima Fast DDS in this configuration."`). + +### Issue 2: `TimeBasedFilter` QoS +- **Problem**: Fast DDS and RTI Connext Micro do not implement minimum separation sample filtering on DataReaders. On Fast DDS, the subscriber received all samples, leading to `DATA_NOT_CORRECT` or failure during test execution. +- **Solution**: Early validation in `shape_configurator_eprosima_fast_dds.h` and `shape_configurator_rti_connext_micro.h` explicitly rejects `--time-filter` with detailed explanation: `"[eProsima Fast DDS] TimeBasedFilter QoS is not supported: Fast DDS does not implement minimum_separation sample filtering on DataReader."`. + +### Issue 3: Fast DDS `Lifespan` QoS Calculation Bug +- **Problem**: In `shape_main.cxx`, Fast DDS lifespan was assigned via: + ```cpp + dw_qos.lifespan().duration = Duration_t(options->lifespan_us * 1e-6); + ``` + Because `Duration_t` expects integer seconds (`int32_t`) and nanoseconds (`uint32_t`), passing `0.25` (for 250 ms) truncated the duration to `0` seconds and `0` nanoseconds. As a result, written samples expired immediately, causing `DATA_NOT_RECEIVED` failures. +- **Solution**: Replaced the specialized constructor call with standard duration decomposition: + ```cpp + dw_qos.lifespan FIELD_ACCESSOR.duration.SECONDS_FIELD_NAME = options->lifespan_us / 1000000; + dw_qos.lifespan FIELD_ACCESSOR.duration.nanosec = (options->lifespan_us % 1000000) * 1000; + ``` + Since `SECONDS_FIELD_NAME` is defined as `seconds` for Fast DDS and `sec` for RTI/OpenDDS, this correctly configures 0 seconds and 250,000,000 nanoseconds. + +### Issue 4: InterCOM DDS Presentation QoS Restrictions +- **Problem**: Kongsberg InterCOM DDS supports `INSTANCE_PRESENTATION_QOS` but does not support `GROUP_PRESENTATION_QOS`, nor does it support coherent access with `TOPIC_PRESENTATION_QOS`. +- **Solution**: Handled and cleanly reported in `shape_configurator_intercom_dds.h` with specific error descriptions. + +### Issue 5: RTI Connext Micro Argument Parsing +- **Problem**: In `shape_main.cxx`, checking if `cft_expression != NULL || color != NULL` caused `-c ` to be incorrectly rejected on RTI Connext Micro as unsupported, even though only ContentFilteredTopic (`--cft`) is unsupported. +- **Solution**: Fixed lines 494–506 so only `--cft` is rejected. + +--- + +## 3. Detailed List of Changes by File + +### `srcCxx/shape_configurator_eprosima_fast_dds.h` +- Added `#define DDS_VENDOR_NAME "eProsima Fast DDS"`. +- Implemented `vendor_check_publisher_options()` and `vendor_check_subscriber_options()`: + - Rejects `ordered_access_enabled` with specific message. + - Rejects `coherent_set_enabled` with specific message. + - Rejects access scopes other than `INSTANCE_PRESENTATION_QOS`. + - Rejects `timebasedfilter_interval_us > 0` on subscriber. + +### `srcCxx/shape_configurator_opendds.h` +- Added `#define DDS_VENDOR_NAME "OpenDDS"`. +- Implemented `vendor_check_publisher_options()` and `vendor_check_subscriber_options()`: + - Rejects `ordered_access_enabled` and `coherent_set_enabled` in this test configuration. + - Rejects access scopes other than default `INSTANCE_PRESENTATION_QOS`. + +### `srcCxx/shape_configurator_rti_connext_micro.h` +- Added `#define DDS_VENDOR_NAME "RTI Connext Micro"`. +- Implemented `vendor_check_publisher_options()` and `vendor_check_subscriber_options()`: + - Rejects `ordered_access_enabled`, `coherent_set_enabled`, and non-instance access scopes. + - Rejects `lifespan_us > 0`. + - Rejects `TRANSIENT_DURABILITY_QOS` and `PERSISTENT_DURABILITY_QOS`. + - Rejects `timebasedfilter_interval_us > 0`. + - Rejects `--cft` (ContentFilteredTopic). + +### `srcCxx/shape_configurator_intercom_dds.h` +- Added `#define DDS_VENDOR_NAME "Kongsberg InterCOM DDS"`. +- Implemented `vendor_check_publisher_options()` and `vendor_check_subscriber_options()`: + - Rejects `GROUP_PRESENTATION_QOS`. + - Rejects coherent access combined with `TOPIC_PRESENTATION_QOS`. + +### `srcCxx/shape_configurator_rti_connext_dds.h` & `srcCxx/shape_configurator_toc_coredx_dds.h` +- Added `#define DDS_VENDOR_NAME "RTI Connext DDS"` and `#define DDS_VENDOR_NAME "Twin Oaks CoreDX"`. +- Implemented `vendor_check_publisher_options()` and `vendor_check_subscriber_options()` returning `true`. + +### `srcCxx/shape_main.cxx` +- **Error Diagnostics**: + - Memory allocations (`topics`, `dws`, `drs`, `previous_handles`) now log `[] Error allocating memory for ... ( requested)`. + - Participant factory, participant, and topic creation logs now report exact topic names, domain IDs, and potential root causes (e.g. missing license or QoS mismatch). + - DataWriter and DataReader creation failures now log `[] Failed to create datawriter/datareader [] for topic: (check QoS compatibility or resource limits)`. + - Replaced all unbranded fallback errors with `[] ...`. +- **Validation Integration**: + - `init_publisher()` and `init_subscriber()` call `vendor_check_publisher_options()` and `vendor_check_subscriber_options()` at startup. +- **Duration Fix**: + - Unified lifespan duration logic for Fast DDS and other vendors. + +### `interoperability_report.py` +- **Output Classification**: + - Detects if any returned code is `PUB_UNSUPPORTED_FEATURE` or `SUB_UNSUPPORTED_FEATURE`. + - Extracts the exact failure reason line matching `not supported` from the failing entity's console output. + - Prints: + ``` + : UNSUPPORTED (: ) + ``` + instead of `ERROR`. + - When an actual failure occurs, prints: + ``` + : FAILED (: got , expected ) + ``` + and extracts any explicit error lines from console output. +- **JUnit Reporting**: + - Sets `test_case.result = [junitparser.Skipped(message)]` for unsupported tests. + - Preserves full compatibility with `generate_xlsx_report.py`, which checks `case.result[0].message` for `PUB_UNSUPPORTED_FEATURE` and `SUB_UNSUPPORTED_FEATURE`. + +--- + +## 4. Verification & Validation + +1. **Python Compilation & Syntax**: + - `interoperability_report.py`, `test_suite.py`, and `generate_xlsx_report.py` successfully compiled with Python 3.9 without syntax errors. +2. **Regex & Reason Extraction Tests**: + - Verified that `re.compile('not supported', re.IGNORECASE)` matches all new vendor messages. + - Tested extraction logic on mock application console outputs. +3. **JUnit XML & Report Compatibility**: + - Verified that `junitparser.Skipped` produces XML with `errors="0"`, `failures="0"`, `skipped="1"`. + - Verified that `generate_xlsx_report.py` correctly detects `PUB_UNSUPPORTED` / `SUB_UNSUPPORTED` / `PUB_SUB_UNSUPPORTED` from the skipped case messages.