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/CHANGELOG.md b/CHANGELOG.md
index 037950b..72dbee7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+### Version: 1.2.0
+#### Date: March-31-2026
+- Added `GetVariantMetadataTags(JObject, string)` and `GetVariantMetadataTags(JArray, string)` as the canonical API for building the `data-csvariants` payload (same behavior as the previous helpers).
+
### Version: 1.1.0
#### Date: March-24-2026
- Added `GetVariantAliases` and `GetDataCsvariantsAttribute` for variant alias extraction and `data-csvariants` serialization; Invalid arguments throw `ArgumentException`.
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/Contentstack.Utils.Tests/VariantAliasesTest.cs b/Contentstack.Utils.Tests/VariantAliasesTest.cs
index 09cecf5..868f60e 100644
--- a/Contentstack.Utils.Tests/VariantAliasesTest.cs
+++ b/Contentstack.Utils.Tests/VariantAliasesTest.cs
@@ -44,13 +44,13 @@ public void GetVariantAliases_SingleEntry_ReturnsAliases()
}
[Fact]
- public void GetDataCsvariantsAttribute_SingleEntry_ReturnsJsonArrayString()
+ public void GetVariantMetadataTags_SingleEntry_ReturnsJsonArrayString()
{
JObject full = ReadJsonRoot("variantsSingleEntry.json");
JObject entry = (JObject)full["entry"];
const string contentTypeUid = "movie";
- JObject result = Utils.GetDataCsvariantsAttribute(entry, contentTypeUid);
+ JObject result = Utils.GetVariantMetadataTags(entry, contentTypeUid);
Assert.True(result["data-csvariants"] != null);
string dataCsvariantsStr = result["data-csvariants"].ToString();
@@ -96,13 +96,13 @@ public void GetVariantAliases_MultipleEntries_ReturnsOneResultPerEntryWithUid()
}
[Fact]
- public void GetDataCsvariantsAttribute_MultipleEntries_ReturnsJsonArrayString()
+ public void GetVariantMetadataTags_MultipleEntries_ReturnsJsonArrayString()
{
JObject full = ReadJsonRoot("variantsEntries.json");
JArray entries = (JArray)full["entries"];
const string contentTypeUid = "movie";
- JObject result = Utils.GetDataCsvariantsAttribute(entries, contentTypeUid);
+ JObject result = Utils.GetVariantMetadataTags(entries, contentTypeUid);
Assert.True(result["data-csvariants"] != null);
string dataCsvariantsStr = result["data-csvariants"].ToString();
@@ -139,9 +139,9 @@ public void GetVariantAliases_ThrowsWhenContentTypeUidEmpty()
}
[Fact]
- public void GetDataCsvariantsAttribute_WhenEntryNull_ReturnsEmptyArrayString()
+ public void GetVariantMetadataTags_WhenEntryNull_ReturnsEmptyArrayString()
{
- JObject result = Utils.GetDataCsvariantsAttribute((JObject)null, "landing_page");
+ JObject result = Utils.GetVariantMetadataTags((JObject)null, "landing_page");
Assert.True(result["data-csvariants"] != null);
Assert.Equal("[]", result["data-csvariants"].ToString());
}
@@ -175,24 +175,52 @@ public void GetVariantAliases_Batch_ThrowsWhenContentTypeUidEmpty()
}
[Fact]
- public void GetDataCsvariantsAttribute_WhenEntriesArrayNull_ReturnsEmptyArrayString()
+ public void GetVariantMetadataTags_WhenEntriesArrayNull_ReturnsEmptyArrayString()
{
- JObject result = Utils.GetDataCsvariantsAttribute((JArray)null, "movie");
+ JObject result = Utils.GetVariantMetadataTags((JArray)null, "movie");
Assert.Equal("[]", result["data-csvariants"].ToString());
}
[Fact]
- public void GetDataCsvariantsAttribute_Batch_ThrowsWhenContentTypeUidNull()
+ public void GetVariantMetadataTags_Batch_ThrowsWhenContentTypeUidNull()
{
var entries = new JArray { new JObject { ["uid"] = "a" } };
- Assert.Throws(() => Utils.GetDataCsvariantsAttribute(entries, null));
+ Assert.Throws(() => Utils.GetVariantMetadataTags(entries, null));
}
[Fact]
- public void GetDataCsvariantsAttribute_Batch_ThrowsWhenContentTypeUidEmpty()
+ public void GetVariantMetadataTags_Batch_ThrowsWhenContentTypeUidEmpty()
{
var entries = new JArray { new JObject { ["uid"] = "a" } };
- Assert.Throws(() => Utils.GetDataCsvariantsAttribute(entries, ""));
+ Assert.Throws(() => Utils.GetVariantMetadataTags(entries, ""));
+ }
+
+ [Fact]
+ public void GetDataCsvariantsAttribute_DelegatesToGetVariantMetadataTags()
+ {
+#pragma warning disable CS0618 // Type or member is obsolete — intentional coverage of backward-compatible alias
+ JObject full = ReadJsonRoot("variantsSingleEntry.json");
+ JObject entry = (JObject)full["entry"];
+ const string contentTypeUid = "movie";
+
+ JObject canonical = Utils.GetVariantMetadataTags(entry, contentTypeUid);
+ JObject legacy = Utils.GetDataCsvariantsAttribute(entry, contentTypeUid);
+ Assert.True(JToken.DeepEquals(canonical, legacy));
+
+ JObject fullMulti = ReadJsonRoot("variantsEntries.json");
+ JArray entries = (JArray)fullMulti["entries"];
+ JObject canonicalBatch = Utils.GetVariantMetadataTags(entries, contentTypeUid);
+ JObject legacyBatch = Utils.GetDataCsvariantsAttribute(entries, contentTypeUid);
+ Assert.True(JToken.DeepEquals(canonicalBatch, legacyBatch));
+
+ JObject nullEntryLegacy = Utils.GetDataCsvariantsAttribute((JObject)null, "x");
+ JObject nullEntryCanonical = Utils.GetVariantMetadataTags((JObject)null, "x");
+ Assert.True(JToken.DeepEquals(nullEntryCanonical, nullEntryLegacy));
+
+ JObject nullArrLegacy = Utils.GetDataCsvariantsAttribute((JArray)null, "x");
+ JObject nullArrCanonical = Utils.GetVariantMetadataTags((JArray)null, "x");
+ Assert.True(JToken.DeepEquals(nullArrCanonical, nullArrLegacy));
+#pragma warning restore CS0618
}
[Fact]
diff --git a/Contentstack.Utils/Utils.cs b/Contentstack.Utils/Utils.cs
index 821df2c..4e51c19 100644
--- a/Contentstack.Utils/Utils.cs
+++ b/Contentstack.Utils/Utils.cs
@@ -337,7 +337,13 @@ public static JArray GetVariantAliases(JArray entries, string contentTypeUid)
return variantResults;
}
- public static JObject GetDataCsvariantsAttribute(JObject entry, string contentTypeUid)
+ ///
+ /// Builds the JSON object used for the data-csvariants HTML attribute payload from a single entry.
+ ///
+ /// Entry JSON (e.g. from the Delivery API), or null to produce an empty payload.
+ /// Content type UID for the entry.
+ /// A with a data-csvariants key whose value is a compact JSON array string.
+ public static JObject GetVariantMetadataTags(JObject entry, string contentTypeUid)
{
if (entry == null)
{
@@ -347,10 +353,16 @@ public static JObject GetDataCsvariantsAttribute(JObject entry, string contentTy
}
JArray entries = new JArray();
entries.Add(entry);
- return GetDataCsvariantsAttribute(entries, contentTypeUid);
+ return GetVariantMetadataTags(entries, contentTypeUid);
}
- public static JObject GetDataCsvariantsAttribute(JArray entries, string contentTypeUid)
+ ///
+ /// Builds the JSON object used for the data-csvariants HTML attribute payload from multiple entries.
+ ///
+ /// Array of entry JSON objects, or null to produce an empty payload.
+ /// Content type UID shared by these entries.
+ /// A with a data-csvariants key whose value is a compact JSON array string.
+ public static JObject GetVariantMetadataTags(JArray entries, string contentTypeUid)
{
JObject result = new JObject();
if (entries == null)
@@ -368,6 +380,24 @@ public static JObject GetDataCsvariantsAttribute(JArray entries, string contentT
return result;
}
+ ///
+ /// Prefer . This alias exists for backward compatibility and will be removed in a future major release.
+ ///
+ [Obsolete("Use GetVariantMetadataTags instead. This method will be removed in a future major release.")]
+ public static JObject GetDataCsvariantsAttribute(JObject entry, string contentTypeUid)
+ {
+ return GetVariantMetadataTags(entry, contentTypeUid);
+ }
+
+ ///
+ /// Prefer . This alias exists for backward compatibility and will be removed in a future major release.
+ ///
+ [Obsolete("Use GetVariantMetadataTags instead. This method will be removed in a future major release.")]
+ public static JObject GetDataCsvariantsAttribute(JArray entries, string contentTypeUid)
+ {
+ return GetVariantMetadataTags(entries, contentTypeUid);
+ }
+
private static JArray ExtractVariantAliasesFromEntry(JObject entry)
{
JArray variantArray = new JArray();
diff --git a/Directory.Build.props b/Directory.Build.props
index 3c2f96f..8bdd5df 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,5 +1,5 @@
- 1.1.0
+ 1.2.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 += "