Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/actions/install-ci-run/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
1 change: 1 addition & 0 deletions .github/actions/run-package-tests/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
11 changes: 11 additions & 0 deletions .github/actions/run-tests/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions .github/actions/upload-omni-github-test-results/action.yml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading