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 += "