Skip to content
Merged
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
62 changes: 55 additions & 7 deletions Scripts/generate_integration_test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,42 @@
from datetime import datetime


def _make_xml_parser():
"""
Harden ElementTree parsing against external entity resolution (XXE).
resolve_entities=False is available on Python 3.8+; see:
https://docs.python.org/3.10/library/xml.html#xml-vulnerabilities
"""
if sys.version_info >= (3, 8):
try:
return ET.XMLParser(resolve_entities=False)
except TypeError:
pass
return ET.XMLParser()


def _sanitize_output_path(output_path):
"""
Reject path traversal: output must resolve under the current working directory.
"""
if not output_path or not isinstance(output_path, str):
raise ValueError("Invalid output path")
cwd = os.path.abspath(os.getcwd())
candidate = os.path.abspath(os.path.normpath(output_path))
try:
common = os.path.commonpath([cwd, candidate])
except ValueError as e:
raise ValueError(
"Output path must be on the same drive as the working directory "
"and must not escape it (path traversal)."
) from e
if common != cwd:
raise ValueError(
f"Output path must be inside the working directory ({cwd}). Refusing: {output_path!r}"
)
return candidate


class IntegrationTestReportGenerator:
def __init__(self, trx_path, coverage_path=None):
self.trx_path = trx_path
Expand All @@ -38,10 +74,16 @@ def __init__(self, trx_path, coverage_path=None):
# ──────────────────── TRX PARSING ────────────────────

def parse_trx(self):
tree = ET.parse(self.trx_path)
tree = ET.parse(self.trx_path, parser=_make_xml_parser())
root = tree.getroot()
ns = {'t': 'http://microsoft.com/schemas/VisualStudio/TeamTest/2010'}

unit_tests_by_id = {}
for ut in root.findall('.//t:UnitTest', ns):
tid = ut.get('id')
if tid:
unit_tests_by_id[tid] = ut

counters = root.find('.//t:ResultSummary/t:Counters', ns)
if counters is not None:
self.results['total'] = int(counters.get('total', 0))
Expand Down Expand Up @@ -82,7 +124,8 @@ def parse_trx(self):
duration_str = result.get('duration', '0')
duration = self._parse_duration(duration_str)

test_def = root.find(f".//t:UnitTest[@id='{test_id}']/t:TestMethod", ns)
ut_el = unit_tests_by_id.get(test_id)
test_def = ut_el.find('t:TestMethod', ns) if ut_el is not None else None
class_name = test_def.get('className', '') if test_def is not None else ''

if 'IntegrationTest' not in class_name:
Expand Down Expand Up @@ -147,7 +190,7 @@ def parse_coverage(self):
if not self.coverage_path or not os.path.exists(self.coverage_path):
return
try:
tree = ET.parse(self.coverage_path)
tree = ET.parse(self.coverage_path, parser=_make_xml_parser())
root = tree.getroot()
self.coverage['lines_pct'] = float(root.get('line-rate', 0)) * 100
self.coverage['branches_pct'] = float(root.get('branch-rate', 0)) * 100
Expand Down Expand Up @@ -331,6 +374,7 @@ def _format_duration_display(self, seconds):
# ──────────────────── HTML GENERATION ────────────────────

def generate_html(self, output_path):
output_path = _sanitize_output_path(output_path)
pass_rate = (self.results['passed'] / self.results['total'] * 100) if self.results['total'] > 0 else 0
duration_display = self._format_duration_display(self.results['duration_seconds'])

Expand All @@ -351,7 +395,7 @@ def generate_html(self, output_path):

with open(output_path, 'w', encoding='utf-8') as f:
f.write(html)
return output_path
return os.path.abspath(output_path)

def _html_head(self):
return f"""<!DOCTYPE html>
Expand Down Expand Up @@ -912,12 +956,16 @@ def main():
output_file = args.output or f'integration-test-report_{timestamp}.html'

print(f"\nGenerating HTML report...")
generator.generate_html(output_file)
try:
resolved_output = generator.generate_html(output_file)
except ValueError as e:
print(f"Error: {e}")
sys.exit(1)

print(f"\n{'=' * 70}")
print(f" Report generated: {os.path.abspath(output_file)}")
print(f" Report generated: {resolved_output}")
print(f"{'=' * 70}")
print(f"\n open {os.path.abspath(output_file)}")
print(f"\n open {resolved_output}")


if __name__ == "__main__":
Expand Down
37 changes: 34 additions & 3 deletions Scripts/run-integration-tests-with-report.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,59 @@ echo "Project: $PROJECT_ROOT"
echo "Run ID: $TIMESTAMP"
echo ""

if ! command -v dotnet >/dev/null 2>&1; then
echo "Error: 'dotnet' was not found on your PATH."
echo "Install the .NET SDK (same major version as the test projects) and open a new terminal, or see:"
echo " https://dotnet.microsoft.com/download"
exit 1
fi

if ! command -v python3 >/dev/null 2>&1; then
echo "Error: 'python3' was not found on your PATH (required for the HTML report)."
exit 1
fi

SETTINGS="$PROJECT_ROOT/$TEST_PROJECT/appsettings.json"
if [ ! -f "$SETTINGS" ]; then
echo "Error: Integration tests require credentials at:"
echo " $SETTINGS"
echo "Copy the template and fill in real values (do not commit secrets):"
echo " cp \"$PROJECT_ROOT/$TEST_PROJECT/appsettings.json.example\" \"$SETTINGS\""
exit 1
fi

# Step 1: Run ONLY integration tests, collect TRX + coverage
TRX_FILE="IntegrationTest-Report-${TIMESTAMP}.trx"
TRX_PATH="$PROJECT_ROOT/$TEST_PROJECT/TestResults/$TRX_FILE"
echo "Step 1: Running integration tests..."
set +e
dotnet test "$PROJECT_ROOT/$TEST_PROJECT/$TEST_PROJECT.csproj" \
--filter "FullyQualifiedName~IntegrationTest" \
--logger "trx;LogFileName=$TRX_FILE" \
--results-directory "$PROJECT_ROOT/$TEST_PROJECT/TestResults" \
--collect:"XPlat code coverage" \
--verbosity quiet || true
--verbosity quiet
TEST_EXIT=$?
set -e

echo ""
echo "Tests completed."
echo "Tests completed (dotnet exit code: $TEST_EXIT)."
echo ""

if [ ! -f "$TRX_PATH" ]; then
echo "Error: TRX file was not created at:"
echo " $TRX_PATH"
echo "Fix the dotnet/test errors above (or install the SDK), then run this script again."
exit 1
fi

# Step 2: Locate the cobertura coverage file (most recent)
COBERTURA=""
if [ -d "$PROJECT_ROOT/$TEST_PROJECT/TestResults" ]; then
COBERTURA=$(find "$PROJECT_ROOT/$TEST_PROJECT/TestResults" \
-name "coverage.cobertura.xml" 2>/dev/null | sort -r | head -1)
fi

TRX_PATH="$PROJECT_ROOT/$TEST_PROJECT/TestResults/$TRX_FILE"
echo "TRX: $TRX_PATH"
echo "Coverage: ${COBERTURA:-Not found}"
echo ""
Expand Down
Loading