diff --git a/.github/actions/install-ci-run/action.yml b/.github/actions/install-ci-run/action.yml index e099be41e561..c7b13c23fbfd 100644 --- a/.github/actions/install-ci-run/action.yml +++ b/.github/actions/install-ci-run/action.yml @@ -54,3 +54,10 @@ runs: path: ${{ github.workspace }}/results/results.xml if-no-files-found: ignore retention-days: 7 + + - name: Upload omni-github test results + if: always() + uses: ./.github/actions/upload-omni-github-test-results + with: + junit-file: ${{ github.workspace }}/results/results.xml + artifact-prefix: pytest-results-${{ github.job }}-${{ runner.arch }} diff --git a/.github/actions/run-package-tests/action.yml b/.github/actions/run-package-tests/action.yml index 264e01a158b8..4f4df2487166 100644 --- a/.github/actions/run-package-tests/action.yml +++ b/.github/actions/run-package-tests/action.yml @@ -150,6 +150,7 @@ runs: include-files: ${{ inputs.include-files }} volume-mount-source: ${{ github.workspace }} extra-pip-packages: ${{ inputs.extra-pip-packages }} + omni-github-artifact-prefix: pytest-results-${{ github.job }}-${{ inputs.container-name }} - name: Check Test Results if: always() diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index ceea3a1c4e65..925045f3f251 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -69,6 +69,10 @@ inputs: description: 'Space-separated pip packages to install inside the Docker container before pytest starts' default: '' required: false + omni-github-artifact-prefix: + description: 'Optional human-readable prefix for the omni-github test-result artifact' + default: '' + required: false runs: using: composite @@ -437,6 +441,13 @@ runs: if-no-files-found: ignore retention-days: 7 + - name: Upload omni-github test results + if: always() + uses: ./.github/actions/upload-omni-github-test-results + with: + junit-file: ${{ inputs.reports-dir }}/${{ inputs.result-file }} + artifact-prefix: ${{ inputs.omni-github-artifact-prefix || format('pytest-results-{0}-{1}', github.job, inputs.container-name) }} + - name: Clean up Docker container if: always() && !cancelled() shell: bash diff --git a/.github/actions/upload-omni-github-test-results/action.yml b/.github/actions/upload-omni-github-test-results/action.yml new file mode 100644 index 000000000000..d00071829483 --- /dev/null +++ b/.github/actions/upload-omni-github-test-results/action.yml @@ -0,0 +1,120 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +name: 'Upload omni-github test results' +description: > + Converts a JUnit XML report into the omni-github test-result artifact + contract and uploads it with the current GitHub repository identity. + +inputs: + junit-file: + description: 'Path to the JUnit XML report to convert' + required: true + artifact-prefix: + description: 'Human-readable artifact prefix; normalized before upload' + required: true + test-tool-id: + description: 'Identifier for the test runner that produced the JUnit report' + default: 'pytest' + required: false + test-type: + description: 'Test type stored on each converted test row' + default: 'pytest' + required: false + app-platform: + description: 'omni-github app.platform value; defaults from runner OS and architecture' + default: '' + required: false + app-config: + description: 'omni-github app.config value; defaults to the GitHub job id' + default: '' + required: false + retention-days: + description: 'GitHub artifact retention in days' + default: '7' + required: false + +runs: + using: composite + steps: + - name: Convert JUnit XML to omni-github results + id: convert + shell: bash + env: + APP_CONFIG: ${{ inputs.app-config }} + APP_PLATFORM: ${{ inputs.app-platform }} + ARTIFACT_PREFIX: ${{ inputs.artifact-prefix }} + JUNIT_FILE: ${{ inputs.junit-file }} + TEST_TOOL_ID: ${{ inputs.test-tool-id }} + TEST_TYPE: ${{ inputs.test-type }} + run: | + set -euo pipefail + + if [ ! -f "$JUNIT_FILE" ]; then + echo "::warning::Skipping omni-github upload because JUnit report was not found: $JUNIT_FILE" + echo "upload=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + artifact_prefix="$( + printf '%s' "$ARTIFACT_PREFIX" \ + | tr '[:upper:]' '[:lower:]' \ + | sed -E 's/[^a-z0-9_]+/-/g; s/-+/-/g; s/^-+//; s/-+$//' + )" + if [ -z "$artifact_prefix" ]; then + artifact_prefix="pytest-results" + fi + + app_platform="$APP_PLATFORM" + if [ -z "$app_platform" ]; then + case "${RUNNER_OS:-unknown}-${RUNNER_ARCH:-unknown}" in + Linux-X64) app_platform="linux-x86_64" ;; + Linux-ARM64) app_platform="linux-aarch64" ;; + Windows-X64) app_platform="windows-x86_64" ;; + Windows-ARM64) app_platform="windows-aarch64" ;; + macOS-X64) app_platform="macos-x86_64" ;; + macOS-ARM64) app_platform="macos-aarch64" ;; + *) app_platform="$(printf '%s-%s' "${RUNNER_OS:-unknown}" "${RUNNER_ARCH:-unknown}" | tr '[:upper:]' '[:lower:]')" ;; + esac + fi + + app_config="$APP_CONFIG" + if [ -z "$app_config" ]; then + app_config="${GITHUB_JOB:-github-job}" + fi + + artifact_dir="${RUNNER_TEMP}/omni-github-test-results/${artifact_prefix}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${GITHUB_JOB}" + rm -rf "$artifact_dir" + mkdir -p "$artifact_dir" + + python3 "$GITHUB_ACTION_PATH/junit_to_omni_github_results.py" \ + --junit-file "$JUNIT_FILE" \ + --output-dir "$artifact_dir" \ + --test-tool-id "$TEST_TOOL_ID" \ + --test-type "$TEST_TYPE" \ + --app-platform "$app_platform" \ + --app-config "$app_config" + + manifest_path="${artifact_dir}/omni-github-test-results-upload.json" + result_path="${artifact_dir}/_testoutput/test_results.json" + if [ ! -f "$manifest_path" ] || [ ! -f "$result_path" ]; then + echo "::notice::Skipping omni-github upload because no schema-valid result artifact was generated for JUnit report: $JUNIT_FILE" + echo "upload=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "artifact_dir=$artifact_dir" >> "$GITHUB_OUTPUT" + echo "artifact_prefix=$artifact_prefix" >> "$GITHUB_OUTPUT" + echo "upload=true" >> "$GITHUB_OUTPUT" + + - name: Upload omni-github test results + if: always() && steps.convert.outputs.upload == 'true' + uses: actions/upload-artifact@v7 + with: + name: ${{ steps.convert.outputs.artifact_prefix }}--v1-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}-${{ job.check_run_id }} + path: ${{ steps.convert.outputs.artifact_dir }} + if-no-files-found: error + retention-days: ${{ inputs.retention-days }} + compression-level: 9 diff --git a/.github/actions/upload-omni-github-test-results/junit_to_omni_github_results.py b/.github/actions/upload-omni-github-test-results/junit_to_omni_github_results.py new file mode 100644 index 000000000000..da0cf601f0df --- /dev/null +++ b/.github/actions/upload-omni-github-test-results/junit_to_omni_github_results.py @@ -0,0 +1,160 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Convert a JUnit XML report into the omni-github test-result artifact format.""" + +from __future__ import annotations + +import argparse +import json +import xml.etree.ElementTree as ET +from pathlib import Path + +_MANIFEST_NAME = "omni-github-test-results-upload.json" +_RESULT_PATH = "_testoutput/test_results.json" + + +def _local_name(tag: str) -> str: + """Return an XML tag name without its namespace.""" + return tag.rsplit("}", maxsplit=1)[-1] + + +def _iter_testcases(root: ET.Element) -> list[ET.Element]: + """Return all JUnit testcase elements from the parsed XML tree.""" + return [element for element in root.iter() if _local_name(element.tag) == "testcase"] + + +def _has_child(testcase: ET.Element, names: set[str]) -> bool: + """Return whether a testcase has a direct child with one of the given names.""" + return any(_local_name(child.tag) in names for child in testcase) + + +def _find_child(testcase: ET.Element, names: set[str]) -> ET.Element | None: + """Return the first direct testcase child with one of the given names.""" + for child in testcase: + if _local_name(child.tag) in names: + return child + return None + + +def _duration_seconds(testcase: ET.Element) -> float: + """Return the testcase duration in seconds.""" + try: + duration = float(testcase.attrib.get("time", "0")) + except ValueError: + duration = 0.0 + return max(duration, 0.0) + + +def _test_id(testcase: ET.Element) -> str: + """Return a stable test identifier from JUnit classname and name fields.""" + name = testcase.attrib.get("name", "").strip() + classname = testcase.attrib.get("classname", "").strip() + if classname and name: + return f"{classname}::{name}" + return name or classname or "unknown-testcase" + + +def _message(element: ET.Element | None) -> str | None: + """Return a bounded JUnit child message, if present.""" + if element is None: + return None + message = element.attrib.get("message", "").strip() + return message[:4096] if message else None + + +def _convert_testcase(testcase: ET.Element, test_type: str) -> dict[str, object]: + """Convert one JUnit testcase element into an omni-github test row.""" + skipped = _find_child(testcase, {"skipped"}) + failed_or_error = _has_child(testcase, {"error", "failure"}) + row: dict[str, object] = { + "test_id": _test_id(testcase), + "passed": not failed_or_error and skipped is None, + "duration": _duration_seconds(testcase), + "test_type": test_type, + } + if skipped is not None: + row["skipped"] = True + if skip_reason := _message(skipped): + row["skip_reason"] = skip_reason + return row + + +def convert_junit( + junit_file: Path, + output_dir: Path, + test_tool_id: str, + test_type: str, + app_platform: str, + app_config: str, +) -> int: + """Convert a JUnit XML report and write the omni-github artifact directory. + + Args: + junit_file: Path to the source JUnit XML report. + output_dir: Directory where the artifact root should be written. + test_tool_id: Identifier for the test tool that produced the report. + test_type: Test type to store on each converted test row. + app_platform: Platform label for the result app metadata. + app_config: Configuration label for the result app metadata. + + Returns: + Number of test cases converted. + """ + root = ET.parse(junit_file).getroot() + tests = [_convert_testcase(testcase, test_type) for testcase in _iter_testcases(root)] + if not tests: + return 0 + + result: dict[str, object] = { + "result_schema_version": 1, + "test_tool_id": test_tool_id, + "app": { + "platform": app_platform, + "config": app_config, + }, + "tests": tests, + } + manifest = { + "schema_version": 1, + "result_paths": [_RESULT_PATH], + } + + result_path = output_dir / _RESULT_PATH + result_path.parent.mkdir(parents=True, exist_ok=True) + result_path.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8") + (output_dir / _MANIFEST_NAME).write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + return len(tests) + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--junit-file", type=Path, required=True, help="Path to the JUnit XML report.") + parser.add_argument("--output-dir", type=Path, required=True, help="Artifact root directory to write.") + parser.add_argument("--test-tool-id", required=True, help="Identifier for the test tool.") + parser.add_argument("--test-type", required=True, help="Test type for each test row.") + parser.add_argument("--app-platform", required=True, help="App platform label.") + parser.add_argument("--app-config", required=True, help="App configuration label.") + return parser.parse_args() + + +def main() -> None: + """Run the converter.""" + args = parse_args() + test_count = convert_junit( + junit_file=args.junit_file, + output_dir=args.output_dir, + test_tool_id=args.test_tool_id, + test_type=args.test_type, + app_platform=args.app_platform, + app_config=args.app_config, + ) + if test_count == 0: + print("No JUnit test cases found; skipping omni-github artifact generation.") + + +if __name__ == "__main__": + main() diff --git a/.github/actions/upload-omni-github-test-results/test_junit_to_omni_github_results.py b/.github/actions/upload-omni-github-test-results/test_junit_to_omni_github_results.py new file mode 100644 index 000000000000..9b03f57d30df --- /dev/null +++ b/.github/actions/upload-omni-github-test-results/test_junit_to_omni_github_results.py @@ -0,0 +1,118 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Tests for converting JUnit XML into omni-github test-result artifacts.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +from types import ModuleType + +_CONVERTER_PATH = Path(__file__).with_name("junit_to_omni_github_results.py") +_MANIFEST_NAME = "omni-github-test-results-upload.json" +_RESULT_PATH = Path("_testoutput/test_results.json") + + +def _load_converter() -> ModuleType: + """Load the converter module from its action-local script path.""" + spec = importlib.util.spec_from_file_location("junit_to_omni_github_results", _CONVERTER_PATH) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_convert_junit_writes_schema_compatible_artifact(tmp_path: Path) -> None: + """Test that JUnit test cases are converted into manifest-listed result rows.""" + junit_file = tmp_path / "junit.xml" + junit_file.write_text( + """ + + + + + + + + + +""", + encoding="utf-8", + ) + output_dir = tmp_path / "artifact" + + test_count = _load_converter().convert_junit( + junit_file=junit_file, + output_dir=output_dir, + test_tool_id="pytest", + test_type="unit", + app_platform="linux-x86_64", + app_config="test-job", + ) + + assert test_count == 3 + manifest = json.loads((output_dir / _MANIFEST_NAME).read_text(encoding="utf-8")) + result = json.loads((output_dir / _RESULT_PATH).read_text(encoding="utf-8")) + assert manifest == { + "schema_version": 1, + "result_paths": [_RESULT_PATH.as_posix()], + } + assert result["result_schema_version"] == 1 + assert result["test_tool_id"] == "pytest" + assert result["app"] == { + "platform": "linux-x86_64", + "config": "test-job", + } + assert result["tests"] == [ + { + "duration": 0.125, + "passed": True, + "test_id": "tests.test_demo::test_passes", + "test_type": "unit", + }, + { + "duration": 1.25, + "passed": False, + "test_id": "tests.test_demo::test_fails", + "test_type": "unit", + }, + { + "duration": 0.0, + "passed": False, + "skip_reason": "not applicable", + "skipped": True, + "test_id": "tests.test_demo::test_skips", + "test_type": "unit", + }, + ] + + +def test_convert_junit_skips_artifact_when_no_testcases(tmp_path: Path) -> None: + """Test that an empty JUnit report does not create a schema-invalid artifact.""" + junit_file = tmp_path / "junit.xml" + junit_file.write_text( + """ + +""", + encoding="utf-8", + ) + output_dir = tmp_path / "artifact" + output_dir.mkdir() + + test_count = _load_converter().convert_junit( + junit_file=junit_file, + output_dir=output_dir, + test_tool_id="pytest", + test_type="unit", + app_platform="linux-x86_64", + app_config="test-job", + ) + + assert test_count == 0 + assert not (output_dir / _MANIFEST_NAME).exists() + assert not (output_dir / _RESULT_PATH).exists()