diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml new file mode 100644 index 0000000..5d436dd --- /dev/null +++ b/.github/workflows/unit-test.yml @@ -0,0 +1,15 @@ +name: Unit Test +on: + pull_request: + push: + +jobs: + unit-test: + runs-on: windows-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4.2.2 + - name: Setup .NET Core @ Latest + uses: actions/setup-dotnet@v4.3.0 + - name: Build solution and run unit tests + run: sh ./Scripts/run-unit-test-case.sh diff --git a/Contentstack.Utils.Tests/Contentstack.Utils.Tests.csproj b/Contentstack.Utils.Tests/Contentstack.Utils.Tests.csproj index ee46f08..4d4e3d4 100644 --- a/Contentstack.Utils.Tests/Contentstack.Utils.Tests.csproj +++ b/Contentstack.Utils.Tests/Contentstack.Utils.Tests.csproj @@ -8,9 +8,9 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -21,11 +21,6 @@ all - - - ..\Contentstack.Utils\bin\Debug\netstandard2.0\Contentstack.Utils.dll - - diff --git a/Directory.Build.props b/Directory.Build.props index 9a2bf6c..3c2f96f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ - 1.0.7 + 1.1.0 diff --git a/Scripts/generate_test_report.py b/Scripts/generate_test_report.py new file mode 100644 index 0000000..16ddfc8 --- /dev/null +++ b/Scripts/generate_test_report.py @@ -0,0 +1,656 @@ +#!/usr/bin/env python3 +""" +Test Report Generator for .NET Utils SDK +Parses TRX (results) + Cobertura (coverage) into a single HTML report. +No external dependencies — uses only Python standard library. +Adapted from CMA SDK integration test report generator. +""" + +import xml.etree.ElementTree as ET +import os +import sys +import re +import argparse +from datetime import datetime + + +class TestReportGenerator: + def __init__(self, trx_path, coverage_path=None): + self.trx_path = trx_path + self.coverage_path = coverage_path + self.results = { + 'total': 0, + 'passed': 0, + 'failed': 0, + 'skipped': 0, + 'duration_seconds': 0, + 'tests': [] + } + self.coverage = { + 'lines_pct': 0, + 'branches_pct': 0, + 'statements_pct': 0, + 'functions_pct': 0 + } + self.file_coverage = [] + + def parse_trx(self): + tree = ET.parse(self.trx_path) + root = tree.getroot() + ns = {'t': 'http://microsoft.com/schemas/VisualStudio/TeamTest/2010'} + + counters = root.find('.//t:ResultSummary/t:Counters', ns) + if counters is not None: + self.results['total'] = int(counters.get('total', 0)) + self.results['passed'] = int(counters.get('passed', 0)) + self.results['failed'] = int(counters.get('failed', 0)) + self.results['skipped'] = int(counters.get('notExecuted', 0)) + + times = root.find('.//t:Times', ns) + if times is not None: + try: + start = times.get('start', '') + finish = times.get('finish', '') + if start and finish: + start_clean = re.sub(r'[+-]\d{2}:\d{2}$', '', start) + finish_clean = re.sub(r'[+-]\d{2}:\d{2}$', '', finish) + for fmt_try in ['%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%dT%H:%M:%S']: + try: + dt_start = datetime.strptime(start_clean, fmt_try) + dt_finish = datetime.strptime(finish_clean, fmt_try) + self.results['duration_seconds'] = (dt_finish - dt_start).total_seconds() + break + except ValueError: + continue + except Exception: + pass + + for result in root.findall('.//t:UnitTestResult', ns): + test_id = result.get('testId', '') + test_name = result.get('testName', '') + outcome = result.get('outcome', 'Unknown') + 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) + class_name = test_def.get('className', '') if test_def is not None else '' + + parts = class_name.split(',')[0].rsplit('.', 1) + file_name = parts[-1] if len(parts) > 1 else class_name + + error_msg = error_trace = None + error_info = result.find('.//t:ErrorInfo', ns) + if error_info is not None: + msg_el = error_info.find('t:Message', ns) + stk_el = error_info.find('t:StackTrace', ns) + if msg_el is not None: + error_msg = msg_el.text + if stk_el is not None: + error_trace = stk_el.text + + self.results['tests'].append({ + 'name': test_name, + 'outcome': outcome, + 'duration': duration, + 'file': file_name, + 'error_message': error_msg, + 'error_stacktrace': error_trace + }) + + def _parse_duration(self, duration_str): + try: + parts = duration_str.split(':') + if len(parts) == 3: + h, m = int(parts[0]), int(parts[1]) + s = float(parts[2]) + total = h * 3600 + m * 60 + s + return f"{total:.2f}s" + except Exception: + pass + return duration_str + + 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) + 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 + self.coverage['statements_pct'] = self.coverage['lines_pct'] + + total_methods = 0 + covered_methods = 0 + for method in root.iter('method'): + total_methods += 1 + lr = float(method.get('line-rate', 0)) + if lr > 0: + covered_methods += 1 + if total_methods > 0: + self.coverage['functions_pct'] = (covered_methods / total_methods) * 100 + + self._parse_file_coverage(root) + except Exception as e: + print(f"Warning: Could not parse coverage file: {e}") + + def _parse_file_coverage(self, root): + file_data = {} + for cls in root.iter('class'): + filename = cls.get('filename', '') + if not filename: + continue + + if filename not in file_data: + file_data[filename] = { + 'lines': {}, + 'branches_covered': 0, + 'branches_total': 0, + 'methods_total': 0, + 'methods_covered': 0, + } + + entry = file_data[filename] + + for method in cls.findall('methods/method'): + entry['methods_total'] += 1 + if float(method.get('line-rate', 0)) > 0: + entry['methods_covered'] += 1 + + for line in cls.iter('line'): + num = int(line.get('number', 0)) + hits = int(line.get('hits', 0)) + is_branch = line.get('branch', 'False').lower() == 'true' + + if num in entry['lines']: + entry['lines'][num]['hits'] = max(entry['lines'][num]['hits'], hits) + if is_branch: + entry['lines'][num]['is_branch'] = True + cond = line.get('condition-coverage', '') + covered, total = self._parse_condition_coverage(cond) + entry['lines'][num]['br_covered'] = max(entry['lines'][num].get('br_covered', 0), covered) + entry['lines'][num]['br_total'] = max(entry['lines'][num].get('br_total', 0), total) + else: + br_covered, br_total = 0, 0 + if is_branch: + cond = line.get('condition-coverage', '') + br_covered, br_total = self._parse_condition_coverage(cond) + entry['lines'][num] = { + 'hits': hits, + 'is_branch': is_branch, + 'br_covered': br_covered, + 'br_total': br_total, + } + + self.file_coverage = [] + for filename in sorted(file_data.keys()): + entry = file_data[filename] + lines_total = len(entry['lines']) + lines_covered = sum(1 for l in entry['lines'].values() if l['hits'] > 0) + uncovered = sorted(num for num, l in entry['lines'].items() if l['hits'] == 0) + + br_total = sum(l.get('br_total', 0) for l in entry['lines'].values() if l.get('is_branch')) + br_covered = sum(l.get('br_covered', 0) for l in entry['lines'].values() if l.get('is_branch')) + + self.file_coverage.append({ + 'filename': filename, + 'lines_pct': (lines_covered / lines_total * 100) if lines_total > 0 else 100, + 'statements_pct': (lines_covered / lines_total * 100) if lines_total > 0 else 100, + 'branches_pct': (br_covered / br_total * 100) if br_total > 0 else 100, + 'functions_pct': (entry['methods_covered'] / entry['methods_total'] * 100) if entry['methods_total'] > 0 else 100, + 'uncovered_lines': uncovered, + }) + + @staticmethod + def _parse_condition_coverage(cond_str): + m = re.match(r'(\d+)%\s*\((\d+)/(\d+)\)', cond_str) + if m: + return int(m.group(2)), int(m.group(3)) + return 0, 0 + + @staticmethod + def _esc(text): + if text is None: + return "" + text = str(text) + return (text + .replace('&', '&') + .replace('<', '<') + .replace('>', '>') + .replace('"', '"') + .replace("'", ''')) + + def _format_duration_display(self, seconds): + if seconds < 60: + return f"{seconds:.1f}s" + elif seconds < 3600: + m = int(seconds // 60) + s = seconds % 60 + return f"{m}m {s:.0f}s" + else: + h = int(seconds // 3600) + m = int((seconds % 3600) // 60) + return f"{h}h {m}m" + + def generate_html(self, output_path): + pass_rate = (self.results['passed'] / self.results['total'] * 100) if self.results['total'] > 0 else 0 + + by_file = {} + for test in self.results['tests']: + by_file.setdefault(test['file'], []).append(test) + + html = self._html_head() + html += self._html_header(pass_rate) + html += self._html_kpi_bar() + html += self._html_pass_rate(pass_rate) + html += self._html_coverage_table() + html += self._html_test_navigation(by_file) + html += self._html_file_coverage_table() + html += self._html_footer() + html += self._html_scripts() + html += "" + + with open(output_path, 'w', encoding='utf-8') as f: + f.write(html) + return output_path + + def _html_head(self): + return """ + + + + + .NET Utils SDK - Unit Test Report + + + +
+""" + + def _html_header(self, pass_rate): + now = datetime.now().strftime('%B %d, %Y at %I:%M %p') + return f""" +
+

Unit Test Results

+

.NET Utils SDK — {now}

+
+""" + + def _html_kpi_bar(self): + r = self.results + return f""" +
+
{r['total']}
Total Tests
+
{r['passed']}
Passed
+
{r['failed']}
Failed
+
{r['skipped']}
Skipped
+
+""" + + def _html_pass_rate(self, pass_rate): + return f""" +
+

Pass Rate

+
+
{pass_rate:.1f}%
+
+
+""" + + def _html_coverage_table(self): + c = self.coverage + if c['lines_pct'] == 0 and c['branches_pct'] == 0: + return "" + + def cov_class(pct): + if pct >= 80: return 'cov-good' + if pct >= 50: return 'cov-warn' + return 'cov-bad' + + return f""" +
+

Global Code Coverage

+ + + + + + + + + + +
StatementsBranchesFunctionsLines
{c['statements_pct']:.1f}%{c['branches_pct']:.1f}%{c['functions_pct']:.1f}%{c['lines_pct']:.1f}%
+
+""" + + def _html_file_coverage_table(self): + if not self.file_coverage: + return "" + + def cov_class(pct): + if pct >= 80: return 'cov-good' + if pct >= 50: return 'cov-warn' + return 'cov-bad' + + c = self.coverage + html = """ +
+

File-wise Code Coverage

+ + + + + + + +""" + html += f""" + + + + + + + +""" + + for fc in self.file_coverage: + uncovered = fc['uncovered_lines'] + if len(uncovered) == 0: + uncov_str = '' + elif len(uncovered) == 1: + uncov_str = str(uncovered[0]) + else: + uncov_str = f"{uncovered[0]}-{uncovered[-1]}" + display_name = fc['filename'] + parts = display_name.replace('\\', '/').rsplit('/', 1) + if len(parts) == 2: + dir_part, base = parts + display_name = f'{self._esc(dir_part)}/{self._esc(base)}' + else: + display_name = self._esc(display_name) + + html += f""" + + + + + + + +""" + + html += """ +
File% Stmts% Branch% Funcs% LinesUncovered Line #s
All files{c['statements_pct']:.1f}%{c['branches_pct']:.1f}%{c['functions_pct']:.1f}%{c['lines_pct']:.1f}%
{display_name}{fc['statements_pct']:.1f}%{fc['branches_pct']:.1f}%{fc['functions_pct']:.1f}%{fc['lines_pct']:.1f}%{self._esc(uncov_str)}
+
+""" + return html + + def _html_test_navigation(self, by_file): + html = '

Test Results by File

' + + for file_name in sorted(by_file.keys()): + tests = by_file[file_name] + passed = sum(1 for t in tests if t['outcome'] == 'Passed') + failed = sum(1 for t in tests if t['outcome'] == 'Failed') + skipped = sum(1 for t in tests if t['outcome'] in ('NotExecuted', 'Inconclusive')) + safe_id = re.sub(r'[^a-zA-Z0-9]', '_', file_name) + + html += f""" +
+
+
+ + {self._esc(file_name)} +
+
+ {passed} passed · + {failed} failed · + {skipped} skipped · + {len(tests)} total +
+
+
+ + + + + + +""" + for idx, test in enumerate(tests): + status_cls = 'status-passed' if test['outcome'] == 'Passed' else 'status-failed' if test['outcome'] == 'Failed' else 'status-skipped' + icon = '✅' if test['outcome'] == 'Passed' else '❌' if test['outcome'] == 'Failed' else '⏭' + test_id = f"test-{safe_id}-{idx}" + + html += f""" + + + + +""" + html += """ + +
Test NameStatus
+
{icon} {self._esc(test['name'])}
+""" + error_msg = test.get('error_message') + error_trace = test.get('error_stacktrace') + if test['outcome'] == 'Failed' and (error_msg or error_trace): + html += f'
' + html += '
' + if error_msg: + html += f'
Error:
{self._esc(error_msg)}
' + if error_trace: + html += f'
Stack Trace
{self._esc(error_trace)}
' + html += '
' + + html += f""" +
{test['outcome']}
+
+
+""" + html += "
" + return html + + def _html_footer(self): + now = datetime.now().strftime('%Y-%m-%d at %H:%M:%S') + return f""" + +""" + + def _html_scripts(self): + return """ + +""" + + +def main(): + parser = argparse.ArgumentParser(description='Unit Test Report Generator for .NET Utils SDK') + parser.add_argument('trx_file', help='Path to the .trx test results file') + parser.add_argument('--coverage', help='Path to coverage.cobertura.xml file', default=None) + parser.add_argument('--output', help='Output HTML file path', default=None) + args = parser.parse_args() + + if not os.path.exists(args.trx_file): + print(f"Error: TRX file not found: {args.trx_file}") + sys.exit(1) + + print("=" * 70) + print(" .NET Utils SDK - Unit Test Report Generator") + print("=" * 70) + + generator = TestReportGenerator(args.trx_file, args.coverage) + + print(f"\nParsing TRX: {args.trx_file}") + generator.parse_trx() + print(f" Found {generator.results['total']} tests") + print(f" Passed: {generator.results['passed']}") + print(f" Failed: {generator.results['failed']}") + print(f" Skipped: {generator.results['skipped']}") + + if args.coverage: + print(f"\nParsing Coverage: {args.coverage}") + generator.parse_coverage() + c = generator.coverage + print(f" Lines: {c['lines_pct']:.1f}%") + print(f" Branches: {c['branches_pct']:.1f}%") + print(f" Functions: {c['functions_pct']:.1f}%") + + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + output_file = args.output or f'unit-test-report_{timestamp}.html' + + print(f"\nGenerating HTML report...") + generator.generate_html(output_file) + + print(f"\n{'=' * 70}") + print(f" Report generated: {os.path.abspath(output_file)}") + print(f"{'=' * 70}") + print(f"\n open {os.path.abspath(output_file)}") + + +if __name__ == "__main__": + main() diff --git a/Scripts/run-test-case.sh b/Scripts/run-test-case.sh index 5931e17..1f80a67 100644 --- a/Scripts/run-test-case.sh +++ b/Scripts/run-test-case.sh @@ -1,36 +1,54 @@ -#!/bin/sh +#!/bin/bash # run-test-case.sh # Contentstack # # Created by Uttam Ukkoji on 12/04/21. -# Copyright © 2020 Contentstack. All rights reserved. +# Copyright © 2026 Contentstack. All rights reserved. -echo "Removing files" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$REPO_ROOT" || exit 1 -TEST_TARGETS=('Contentstack.Utils.Tests') +TEST_PROJECT="Contentstack.Utils.Tests" +TEST_RESULTS_DIR="$REPO_ROOT/$TEST_PROJECT/TestResults" -for i in "${TEST_TARGETS[@]}" -do - rm -rf "$i/TestResults" -done +echo "Removing files" +rm -rf "$TEST_RESULTS_DIR" DATE=$(date +'%d-%b-%Y') - FILE_NAME="Contentstack-DotNet-Test-Case-$DATE" echo "Running test case..." -dotnet test --logger "trx;LogFileName=Report-$FILE_NAME.trx" --collect:"XPlat code coverage" +dotnet test "$REPO_ROOT/Contentstack.Utils.sln" \ + --logger "trx;LogFileName=Report-$FILE_NAME.trx" \ + --collect:"XPlat code coverage" echo "Test case Completed..." echo "Generating code coverage report..." -for i in "${TEST_TARGETS[@]}" -do - cd "$i" - reportgenerator "-reports:**/**/coverage.cobertura.xml" "-targetdir:TestResults/Coverage-$FILE_NAME" -reporttypes:HTML - cd .. -done +reports=$(find "$TEST_RESULTS_DIR" -name "coverage.cobertura.xml" 2>/dev/null | paste -sd ';' -) +if [ -z "$reports" ]; then + reports=$(find "$REPO_ROOT/TestResults" -name "coverage.cobertura.xml" 2>/dev/null | paste -sd ';' -) +fi + +if [ -z "$reports" ]; then + echo "No coverage.cobertura.xml found under $TEST_RESULTS_DIR or $REPO_ROOT/TestResults." + exit 1 +fi + +COVERAGE_OUT="$TEST_RESULTS_DIR/Coverage-$FILE_NAME" +mkdir -p "$COVERAGE_OUT" + +TRX_FILE="$TEST_RESULTS_DIR/Report-$FILE_NAME.trx" +COVERAGE_FILE="${reports%%;*}" + +python3 "$REPO_ROOT/Scripts/generate_test_report.py" \ + "$TRX_FILE" \ + --coverage "$COVERAGE_FILE" \ + --output "$COVERAGE_OUT/index.html" -echo "Code coverage report generate." +echo "" +echo "Code coverage report generated." +echo "Open: $COVERAGE_OUT/index.html" diff --git a/Scripts/run-unit-test-case.sh b/Scripts/run-unit-test-case.sh new file mode 100644 index 0000000..8cefe94 --- /dev/null +++ b/Scripts/run-unit-test-case.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +# run-unit-test-case.sh +# Contentstack +# +# Created by Uttam Ukkoji on 30/03/2023. +# Copyright © 2026 Contentstack. All rights reserved. + +echo "Removing files" +rm -rf "./Contentstack.Utils.Tests/TestResults" + +FILE_NAME="Contentstack-DotNet-Test-Case" + +echo "Running test case..." +dotnet test "Contentstack.Utils.Tests/Contentstack.Utils.Tests.csproj" --logger "trx;LogFileName=Report-$FILE_NAME.trx" --collect:"XPlat code coverage" + +echo "Test case Completed..."