diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 563169a3d..f79f8f352 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -32,7 +32,7 @@ jobs: dotnet new uninstall Neo.SmartContract.Template dotnet remove ./src/Neo.SmartContract.Template/bin/Debug/Nep17Contract.csproj package Neo.SmartContract.Framework dotnet add ./src/Neo.SmartContract.Template/bin/Debug/Nep17Contract.csproj reference ./src/Neo.SmartContract.Framework/Neo.SmartContract.Framework.csproj - dotnet ./src/Neo.Compiler.CSharp/bin/Debug/net7.0/nccs.dll -d ./src/Neo.SmartContract.Template/bin/Debug/Nep17Contract.csproj -o ./tests/Neo.SmartContract.Template.UnitTests/templates/neocontractnep17/Artifacts/ --generate-artifacts source + dotnet ./src/Neo.Compiler.CSharp/bin/Debug/net7.0/nccs.dll ./src/Neo.SmartContract.Template/bin/Debug/Nep17Contract.csproj -o ./tests/Neo.SmartContract.Template.UnitTests/templates/neocontractnep17/Artifacts/ --generate-artifacts source --debug - name: Build Solution run: dotnet build ./neo-devpack-dotnet.sln - name: Check format diff --git a/src/Neo.SmartContract.Testing/Coverage/CoverageBase.cs b/src/Neo.SmartContract.Testing/Coverage/CoverageBase.cs index 32a186cde..cc835a92e 100644 --- a/src/Neo.SmartContract.Testing/Coverage/CoverageBase.cs +++ b/src/Neo.SmartContract.Testing/Coverage/CoverageBase.cs @@ -48,30 +48,12 @@ public abstract class CoverageBase /// /// Covered lines percentage /// - public float CoveredLinesPercentage - { - get - { - var total = TotalLines; - if (total == 0) return 1F; - - return (float)CoveredLines / total; - } - } + public decimal CoveredLinesPercentage => CalculateHitRate(TotalLines, CoveredLines); /// /// Covered branch percentage /// - public float CoveredBranchPercentage - { - get - { - var total = TotalBranches; - if (total == 0) return 1F; - - return (float)CoveredBranches / total; - } - } + public decimal CoveredBranchPercentage => CalculateHitRate(TotalBranches, CoveredBranches); /// /// Get Coverage lines from the Contract coverage @@ -111,6 +93,9 @@ public IEnumerable GetCoverageBranchFrom(int offset, int length) } } + public static decimal CalculateHitRate(int total, int hits) + => total == 0 ? 1m : new decimal(hits) / new decimal(total); + // Allow to sum coverages public static CoverageBase? operator +(CoverageBase? a, CoverageBase? b) diff --git a/src/Neo.SmartContract.Testing/Coverage/CoverageReporting.cs b/src/Neo.SmartContract.Testing/Coverage/CoverageReporting.cs new file mode 100644 index 000000000..4e52d85e8 --- /dev/null +++ b/src/Neo.SmartContract.Testing/Coverage/CoverageReporting.cs @@ -0,0 +1,28 @@ +using Palmmedia.ReportGenerator.Core; +using System.IO; + +namespace Neo.SmartContract.Testing.Coverage +{ + public class CoverageReporting + { + /// + /// Generate report from cobertura + /// + /// Coverage file + /// Output dir + /// True if was success + public static bool CreateReport(string file, string outputDir) + { + try + { + // Reporting + + Program.Main(new string[] { $"-reports:{Path.GetFullPath(file)}", $"-targetdir:{Path.GetFullPath(outputDir)}" }); + return true; + } + catch { } + + return false; + } + } +} diff --git a/src/Neo.SmartContract.Testing/Coverage/CoveredContract.cs b/src/Neo.SmartContract.Testing/Coverage/CoveredContract.cs index ca2e11ca3..7eddb8f80 100644 --- a/src/Neo.SmartContract.Testing/Coverage/CoveredContract.cs +++ b/src/Neo.SmartContract.Testing/Coverage/CoveredContract.cs @@ -1,8 +1,10 @@ using Neo.SmartContract.Manifest; +using Neo.SmartContract.Testing.Coverage.Formats; using Neo.VM; using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Text; @@ -14,8 +16,8 @@ public class CoveredContract : CoverageBase { #region Internal - private readonly Dictionary _lines = new(); - private readonly Dictionary _branches = new(); + private readonly SortedDictionary _lines = new(); + private readonly SortedDictionary _branches = new(); #endregion @@ -215,6 +217,16 @@ private CoveredMethod CreateMethod( return Methods.FirstOrDefault(m => m.Method.Equals(method)); } + internal bool TryGetLine(int offset, [NotNullWhen(true)] out CoverageHit? lineHit) + { + return _lines.TryGetValue(offset, out lineHit); + } + + internal bool TryGetBranch(int offset, [NotNullWhen(true)] out CoverageBranch? branch) + { + return _branches.TryGetValue(offset, out branch); + } + /// /// Join coverage /// @@ -277,150 +289,43 @@ public string Dump(DumpFormat format = DumpFormat.Console) /// Coverage dump internal string Dump(DumpFormat format, params CoveredMethod[] methods) { - var builder = new StringBuilder(); - using var sourceCode = new StringWriter(builder) - { - NewLine = "\n" - }; - switch (format) { case DumpFormat.Console: { - var coverLines = $"{CoveredLinesPercentage:P2}"; - var coverBranch = $"{CoveredBranchPercentage:P2}"; - sourceCode.WriteLine($"{Hash} [{coverLines} - {coverBranch}]"); - - List rows = new(); - var max = new int[] { "Method".Length, "Line ".Length, "Branch".Length }; - - foreach (var method in methods.OrderBy(u => u.Method.Name).OrderByDescending(u => u.CoveredLinesPercentage)) - { - coverLines = $"{method.CoveredLinesPercentage:P2}"; - coverBranch = $"{method.CoveredBranchPercentage:P2}"; - rows.Add(new string[] { method.Method.ToString(), coverLines, coverBranch }); - - max[0] = Math.Max(method.Method.ToString().Length, max[0]); - max[1] = Math.Max(coverLines.Length, max[1]); - max[2] = Math.Max(coverLines.Length, max[2]); - } - - sourceCode.WriteLine($"┌-{"─".PadLeft(max[0], '─')}-┬-{"─".PadLeft(max[1], '─')}-┬-{"─".PadLeft(max[1], '─')}-┐"); - sourceCode.WriteLine($"│ {string.Format($"{{0,-{max[0]}}}", "Method", max[0])} │ {string.Format($"{{0,{max[1]}}}", "Line ", max[1])} │ {string.Format($"{{0,{max[2]}}}", "Branch", max[1])} │"); - sourceCode.WriteLine($"├-{"─".PadLeft(max[0], '─')}-┼-{"─".PadLeft(max[1], '─')}-┼-{"─".PadLeft(max[1], '─')}-┤"); - - foreach (var print in rows) - { - sourceCode.WriteLine($"│ {string.Format($"{{0,-{max[0]}}}", print[0], max[0])} │ {string.Format($"{{0,{max[1]}}}", print[1], max[1])} │ {string.Format($"{{0,{max[1]}}}", print[2], max[2])} │"); - } - - sourceCode.WriteLine($"└-{"─".PadLeft(max[0], '─')}-┴-{"─".PadLeft(max[1], '─')}-┴-{"─".PadLeft(max[2], '─')}-┘"); - break; + return Dump(new ConsoleFormat(this, methods)); } case DumpFormat.Html: { - sourceCode.WriteLine(@" - - - - -NEF coverage Report - - - -"); - - sourceCode.WriteLine($@" -
-
{Hash}
-
 {CoveredBranchPercentage:P2} 
-
 {CoveredLinesPercentage:P2} 
-
-
-
-"); - - foreach (var method in methods.OrderBy(u => u.Method.Name).OrderByDescending(u => u.CoveredLinesPercentage)) - { - var kind = "low"; - if (method.CoveredLinesPercentage > 0.7) kind = "medium"; - if (method.CoveredLinesPercentage > 0.8) kind = "high"; - - sourceCode.WriteLine($@" -
-
{method.Method}
-
 {method.CoveredBranchPercentage:P2} 
-
 {method.CoveredLinesPercentage:P2} 
-
-
-"); - sourceCode.WriteLine($@"
"); - - foreach (var hit in method.Lines) - { - var noHit = hit.Hits == 0 ? "no-" : ""; - var icon = hit.Hits == 0 ? "✘" : "✔"; - var branch = ""; - - if (_branches.TryGetValue(hit.Offset, out var b)) - { - branch = $" [ᛦ {b.Hits}/{b.Count}]"; - } - - sourceCode.WriteLine($@"
{icon}{hit.Hits} Hits{hit.Description}{branch}
"); - } - - sourceCode.WriteLine($@"
-"); - } - - sourceCode.WriteLine(@" -
- + } - - -"); - break; - } + /// + /// Dump to format + /// + /// Format + /// Debug Info + /// Covertura + public string Dump(ICoverageFormat format) + { + Dictionary outputMap = new(); + + void writeAttachment(string filename, Action writestream) + { + using MemoryStream stream = new(); + writestream(stream); + var text = Encoding.UTF8.GetString(stream.ToArray()); + outputMap.Add(filename, text); } - return builder.ToString(); + format.WriteReport(writeAttachment); + return outputMap.First().Value; } /// diff --git a/src/Neo.SmartContract.Testing/Coverage/Formats/CoberturaFormat.ContractCoverageWriter.cs b/src/Neo.SmartContract.Testing/Coverage/Formats/CoberturaFormat.ContractCoverageWriter.cs new file mode 100644 index 000000000..9bd0c28fb --- /dev/null +++ b/src/Neo.SmartContract.Testing/Coverage/Formats/CoberturaFormat.ContractCoverageWriter.cs @@ -0,0 +1,166 @@ +using System.Collections.Generic; +using System.Linq; +using System.Xml; + +namespace Neo.SmartContract.Testing.Coverage.Formats +{ + public partial class CoberturaFormat + { + internal class ContractCoverageWriter + { + readonly CoveredContract Contract; + readonly NeoDebugInfo DebugInfo; + + /// + /// Constructor + /// + /// Contract + /// Debug info + public ContractCoverageWriter(CoveredContract contract, NeoDebugInfo debugInfo) + { + Contract = contract; + DebugInfo = debugInfo; + } + + public void WritePackage(XmlWriter writer) + { + var (lineCount, hitCount) = GetLineRate(Contract, DebugInfo.Methods.SelectMany(m => m.SequencePoints)); + var lineRate = CoverageBase.CalculateHitRate(lineCount, hitCount); + var (branchCount, branchHit) = GetBranchRate(Contract, DebugInfo.Methods); + var branchRate = CoverageBase.CalculateHitRate(branchCount, branchHit); + + writer.WriteStartElement("package"); + // TODO: complexity + writer.WriteAttributeString("name", DebugInfo.Hash.ToString()); + writer.WriteAttributeString("scripthash", $"{DebugInfo.Hash}"); + writer.WriteAttributeString("line-rate", $"{lineRate:N4}"); + writer.WriteAttributeString("branch-rate", $"{branchRate:N4}"); + writer.WriteStartElement("classes"); + { + foreach (var group in DebugInfo.Methods.GroupBy(NamespaceAndFilename)) + { + WriteClass(writer, group.Key.@namespace, group.Key.filename, group); + } + } + writer.WriteEndElement(); + writer.WriteEndElement(); + + (string @namespace, string filename) NamespaceAndFilename(NeoDebugInfo.Method method) + { + var indexes = method.SequencePoints + .Select(sp => sp.Document) + .Distinct() + .ToList(); + if (indexes.Count == 1) + { + var index = indexes[0]; + if (index >= 0 && index < DebugInfo.Documents.Count) + { + return (method.Namespace, DebugInfo.Documents[index]); + } + } + return (method.Namespace, string.Empty); + } + } + + internal void WriteClass(XmlWriter writer, string name, string filename, IEnumerable methods) + { + var (lineCount, hitCount) = GetLineRate(Contract, methods.SelectMany(m => m.SequencePoints)); + var lineRate = CoverageBase.CalculateHitRate(lineCount, hitCount); + var (branchCount, branchHit) = GetBranchRate(Contract, methods); + var branchRate = CoverageBase.CalculateHitRate(branchCount, branchHit); + + writer.WriteStartElement("class"); + // TODO: complexity + writer.WriteAttributeString("name", name); + if (filename.Length > 0) + { writer.WriteAttributeString("filename", filename); } + writer.WriteAttributeString("line-rate", $"{lineRate:N4}"); + writer.WriteAttributeString("branch-rate", $"{branchRate:N4}"); + + writer.WriteStartElement("methods"); + foreach (var method in methods) + { + WriteMethod(writer, method); + } + writer.WriteEndElement(); + + writer.WriteStartElement("lines"); + foreach (var method in methods) + { + foreach (var sp in method.SequencePoints) + { + WriteLine(writer, method, sp); + } + } + writer.WriteEndElement(); + + writer.WriteEndElement(); + } + + internal void WriteMethod(XmlWriter writer, NeoDebugInfo.Method method) + { + var signature = string.Join(", ", method.Parameters.Select(p => p.Type)); + var (lineCount, hitCount) = GetLineRate(Contract, method.SequencePoints); + var lineRate = CoverageBase.CalculateHitRate(lineCount, hitCount); + var (branchCount, branchHit) = GetBranchRate(Contract, method); + var branchRate = CoverageBase.CalculateHitRate(branchCount, branchHit); + + writer.WriteStartElement("method"); + writer.WriteAttributeString("name", method.Name); + writer.WriteAttributeString("signature", $"({signature})"); + writer.WriteAttributeString("line-rate", $"{lineRate:N4}"); + writer.WriteAttributeString("branch-rate", $"{branchRate:N4}"); + writer.WriteStartElement("lines"); + foreach (var sp in method.SequencePoints) + { + WriteLine(writer, method, sp); + } + writer.WriteEndElement(); + writer.WriteEndElement(); + } + + internal void WriteLine(XmlWriter writer, NeoDebugInfo.Method method, NeoDebugInfo.SequencePoint sp) + { + var hits = Contract.TryGetLine(sp.Address, out var value) ? value.Hits : 0; + + writer.WriteStartElement("line"); + writer.WriteAttributeString("number", $"{sp.Start.Line}"); + writer.WriteAttributeString("address", $"{sp.Address}"); + writer.WriteAttributeString("hits", $"{hits}"); + + if (!Contract.TryGetBranch(sp.Address, out var branch)) + { + writer.WriteAttributeString("branch", $"{false}"); + } + else + { + int branchCount = branch.Count; + int branchHit = branch.Hits; + var branchRate = CoverageBase.CalculateHitRate(branchCount, branchHit); + + writer.WriteAttributeString("branch", $"{true}"); + writer.WriteAttributeString("condition-coverage", $"{branchRate * 100:N}% ({branchHit}/{branchCount})"); + writer.WriteStartElement("conditions"); + + foreach (var (address, opCode) in GetBranchInstructions(Contract, method, sp)) + { + var (condBranchCount, condContinueCount) = Contract.TryGetBranch(address, out var brach) ? + (brach.Count, brach.Hits) : (0, 0); + var coverage = condBranchCount == 0 ? 0m : 1m; + coverage += condContinueCount == 0 ? 0m : 1m; + + writer.WriteStartElement("condition"); + writer.WriteAttributeString("number", $"{address}"); + writer.WriteAttributeString("type", $"{opCode}"); + writer.WriteAttributeString("coverage", $"{coverage / 2m * 100m}%"); + writer.WriteEndElement(); + } + + writer.WriteEndElement(); + } + writer.WriteEndElement(); + } + } + } +} diff --git a/src/Neo.SmartContract.Testing/Coverage/Formats/CoberturaFormat.cs b/src/Neo.SmartContract.Testing/Coverage/Formats/CoberturaFormat.cs new file mode 100644 index 000000000..b03f827c2 --- /dev/null +++ b/src/Neo.SmartContract.Testing/Coverage/Formats/CoberturaFormat.cs @@ -0,0 +1,180 @@ +using Neo.VM; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml; + +namespace Neo.SmartContract.Testing.Coverage.Formats +{ + public partial class CoberturaFormat : ICoverageFormat + { + /// + /// Contract + /// + public IReadOnlyList<(CoveredContract Contract, NeoDebugInfo DebugInfo)> Contracts { get; } + + /// + /// Constructor + /// + /// Contracts + public CoberturaFormat(params (CoveredContract Contract, NeoDebugInfo DebugInfo)[] contracts) + { + Contracts = contracts; + } + + public void WriteReport(Action> writeAttachement) + { + writeAttachement("coverage.cobertura.xml", stream => + { + StreamWriter textWriter = new(stream); + XmlTextWriter xmlWriter = new(textWriter) { Formatting = Formatting.Indented }; + WriteReport(xmlWriter, Contracts); + xmlWriter.Flush(); + textWriter.Flush(); + }); + } + + internal void WriteReport(XmlWriter writer, IReadOnlyList<(CoveredContract Contract, NeoDebugInfo DebugInfo)> coverage) + { + int linesValid = 0, linesCovered = 0; + int branchesValid = 0, branchesCovered = 0; + + foreach (var entry in coverage) + { + var (lineCount, hitCount) = GetLineRate(entry.Contract, entry.DebugInfo.Methods.SelectMany(m => m.SequencePoints)); + linesValid += lineCount; + linesCovered += hitCount; + + var (branchCount, branchHit) = GetBranchRate(entry.Contract, entry.DebugInfo.Methods); + branchesValid += branchCount; + branchesCovered += branchHit; + } + + var lineRate = CoverageBase.CalculateHitRate(linesValid, linesCovered); + var branchRate = CoverageBase.CalculateHitRate(branchesValid, branchesCovered); + + writer.WriteStartDocument(); + writer.WriteStartElement("coverage"); + writer.WriteAttributeString("line-rate", $"{lineRate:N4}"); + writer.WriteAttributeString("lines-covered", $"{linesCovered}"); + writer.WriteAttributeString("lines-valid", $"{linesValid}"); + writer.WriteAttributeString("branch-rate", $"{branchRate:N4}"); + writer.WriteAttributeString("branches-covered", $"{branchesCovered}"); + writer.WriteAttributeString("branches-valid", $"{branchesValid}"); + writer.WriteAttributeString("version", typeof(CoberturaFormat).Assembly.GetVersion()); + writer.WriteAttributeString("timestamp", $"{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"); + + writer.WriteStartElement("sources"); + foreach (var contract in coverage) + { + writer.WriteElementString("source", contract.DebugInfo.DocumentRoot); + } + writer.WriteEndElement(); + + writer.WriteStartElement("packages"); + + foreach (var contract in coverage) + { + var ccWriter = new ContractCoverageWriter(contract.Contract, contract.DebugInfo); + ccWriter.WritePackage(writer); + } + + writer.WriteEndElement(); + writer.WriteEndElement(); + } + + private static (int branchCount, int branchHit) GetBranchRate(CoveredContract contract, IEnumerable methods) + { + int branchCount = 0, branchHit = 0; + foreach (var method in methods) + { + var rate = GetBranchRate(contract, method); + + branchCount += rate.branchCount; + branchHit += rate.branchHit; + } + return (branchCount, branchHit); + } + + private static (int branchCount, int branchHit) GetBranchRate(CoveredContract contract, NeoDebugInfo.Method method) + { + int branchCount = 0, branchHit = 0; + + foreach (var sp in method.SequencePoints) + { + if (contract.TryGetBranch(sp.Address, out var branch)) + { + branchCount += branch.Count; + branchHit += branch.Hits; + } + } + + return (branchCount, branchHit); + } + + private static (int lineCount, int hitCount) GetLineRate(CoveredContract contract, IEnumerable lines) + { + int lineCount = 0, hitCount = 0; + + foreach (var line in lines) + { + lineCount++; + if (contract.TryGetLine(line.Address, out var hit) && hit.Hits > 0) + { + hitCount++; + } + } + + return (lineCount, hitCount); + } + + public static IEnumerable<(int address, OpCode opCode)> GetBranchInstructions( + CoveredContract contract, NeoDebugInfo.Method method, NeoDebugInfo.SequencePoint sequencePoint + ) + { + var address = sequencePoint.Address; + var lines = contract.Lines.Where(u => u.Offset >= address).ToArray(); + var last = GetLineLastAddress(lines, method, Array.IndexOf(method.SequencePoints.ToArray(), sequencePoint)); + + foreach (var line in lines) + { + if (contract.TryGetBranch(address, out var branch)) // IsBranchInstruction + { + //yield return (address, ins.OpCode); + yield return (address, OpCode.NOP); + } + } + } + + public static int GetLineLastAddress(CoverageHit[] lines, NeoDebugInfo.Method method, int index) + { + var nextIndex = index + 1; + if (nextIndex >= method.SequencePoints.Count) + { + // if we're on the last SP of the method, return the method end address + return method.Range.End; + } + else + { + var nextSPAddress = method.SequencePoints[index + 1].Address; + var point = method.SequencePoints[index]; + var address = point.Address; + + foreach (var line in lines) + { + if (line.Offset >= nextSPAddress) + { + return address; + } + else + { + address = line.Offset; + } + } + + return address; + } + } + } +} diff --git a/src/Neo.SmartContract.Testing/Coverage/Formats/ConsoleFormat.cs b/src/Neo.SmartContract.Testing/Coverage/Formats/ConsoleFormat.cs new file mode 100644 index 000000000..426351c27 --- /dev/null +++ b/src/Neo.SmartContract.Testing/Coverage/Formats/ConsoleFormat.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Neo.SmartContract.Testing.Coverage.Formats +{ + public partial class ConsoleFormat : ICoverageFormat + { + /// + /// Contract + /// + public CoveredContract Contract { get; } + + /// + /// Selective methods + /// + public CoveredMethod[] Methods { get; } + + /// + /// Constructor + /// + /// Contract + /// Methods + public ConsoleFormat(CoveredContract contract, params CoveredMethod[] methods) + { + Contract = contract; + Methods = methods; + } + + public void WriteReport(Action> writeAttachement) + { + writeAttachement("coverage.cobertura.txt", stream => + { + using var writer = new StreamWriter(stream) + { + NewLine = "\n" + }; + WriteReport(writer); + writer.Flush(); + }); + } + + private void WriteReport(StreamWriter writer) + { + var coverLines = $"{Contract.CoveredLinesPercentage:P2}"; + var coverBranch = $"{Contract.CoveredBranchPercentage:P2}"; + writer.WriteLine($"{Contract.Hash} [{coverLines} - {coverBranch}]"); + + List rows = new(); + var max = new int[] { "Method".Length, "Line ".Length, "Branch".Length }; + + foreach (var method in Methods.OrderBy(u => u.Method.Name).OrderByDescending(u => u.CoveredLinesPercentage)) + { + coverLines = $"{method.CoveredLinesPercentage:P2}"; + coverBranch = $"{method.CoveredBranchPercentage:P2}"; + rows.Add(new string[] { method.Method.ToString(), coverLines, coverBranch }); + + max[0] = Math.Max(method.Method.ToString().Length, max[0]); + max[1] = Math.Max(coverLines.Length, max[1]); + max[2] = Math.Max(coverLines.Length, max[2]); + } + + writer.WriteLine($"┌-{"─".PadLeft(max[0], '─')}-┬-{"─".PadLeft(max[1], '─')}-┬-{"─".PadLeft(max[1], '─')}-┐"); + writer.WriteLine($"│ {string.Format($"{{0,-{max[0]}}}", "Method", max[0])} │ {string.Format($"{{0,{max[1]}}}", "Line ", max[1])} │ {string.Format($"{{0,{max[2]}}}", "Branch", max[1])} │"); + writer.WriteLine($"├-{"─".PadLeft(max[0], '─')}-┼-{"─".PadLeft(max[1], '─')}-┼-{"─".PadLeft(max[1], '─')}-┤"); + + foreach (var print in rows) + { + writer.WriteLine($"│ {string.Format($"{{0,-{max[0]}}}", print[0], max[0])} │ {string.Format($"{{0,{max[1]}}}", print[1], max[1])} │ {string.Format($"{{0,{max[1]}}}", print[2], max[2])} │"); + } + + writer.WriteLine($"└-{"─".PadLeft(max[0], '─')}-┴-{"─".PadLeft(max[1], '─')}-┴-{"─".PadLeft(max[2], '─')}-┘"); + } + } +} diff --git a/src/Neo.SmartContract.Testing/Coverage/Formats/ICoverageFormat.cs b/src/Neo.SmartContract.Testing/Coverage/Formats/ICoverageFormat.cs new file mode 100644 index 000000000..056273e57 --- /dev/null +++ b/src/Neo.SmartContract.Testing/Coverage/Formats/ICoverageFormat.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace Neo.SmartContract.Testing.Coverage.Formats +{ + public interface ICoverageFormat + { + void WriteReport(Action> writeAttachement); + } +} diff --git a/src/Neo.SmartContract.Testing/Coverage/Formats/IntructionHtmlFormat.cs b/src/Neo.SmartContract.Testing/Coverage/Formats/IntructionHtmlFormat.cs new file mode 100644 index 000000000..25d0101ec --- /dev/null +++ b/src/Neo.SmartContract.Testing/Coverage/Formats/IntructionHtmlFormat.cs @@ -0,0 +1,145 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; + +namespace Neo.SmartContract.Testing.Coverage.Formats +{ + public partial class IntructionHtmlFormat : ICoverageFormat + { + /// + /// Contract + /// + public CoveredContract Contract { get; } + + /// + /// Selective methods + /// + public CoveredMethod[] Methods { get; } + + /// + /// Constructor + /// + /// Contract + /// Methods + public IntructionHtmlFormat(CoveredContract contract, params CoveredMethod[] methods) + { + Contract = contract; + Methods = methods; + } + + public void WriteReport(Action> writeAttachement) + { + writeAttachement("coverage.cobertura.html", stream => + { + using var writer = new StreamWriter(stream) + { + NewLine = "\n" + }; + WriteReport(writer); + writer.Flush(); + }); + } + + private void WriteReport(StreamWriter writer) + { + writer.WriteLine(@" + + + + +NEF coverage Report + + + +"); + + writer.WriteLine($@" +
+
{Contract.Hash}
+
 {Contract.CoveredBranchPercentage:P2} 
+
 {Contract.CoveredLinesPercentage:P2} 
+
+
+
+"); + + foreach (var method in Methods.OrderBy(u => u.Method.Name).OrderByDescending(u => u.CoveredLinesPercentage)) + { + var kind = "low"; + if (method.CoveredLinesPercentage > 0.7M) kind = "medium"; + if (method.CoveredLinesPercentage > 0.8M) kind = "high"; + + writer.WriteLine($@" +
+
{method.Method}
+
 {method.CoveredBranchPercentage:P2} 
+
 {method.CoveredLinesPercentage:P2} 
+
+
+"); + writer.WriteLine($@"
"); + + foreach (var hit in method.Lines) + { + var noHit = hit.Hits == 0 ? "no-" : ""; + var icon = hit.Hits == 0 ? "✘" : "✔"; + var branch = ""; + + if (Contract.TryGetBranch(hit.Offset, out var b)) + { + branch = $" [ᛦ {b.Hits}/{b.Count}]"; + } + + writer.WriteLine($@"
{icon}{hit.Hits} Hits{hit.Description}{branch}
"); + } + + writer.WriteLine($@"
+"); + } + + writer.WriteLine(@" +
+ + + + +"); + } + } +} diff --git a/src/Neo.SmartContract.Testing/Coverage/NeoDebugInfo.Internals.cs b/src/Neo.SmartContract.Testing/Coverage/NeoDebugInfo.Internals.cs new file mode 100644 index 000000000..ff4626ace --- /dev/null +++ b/src/Neo.SmartContract.Testing/Coverage/NeoDebugInfo.Internals.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.Diagnostics; + +namespace Neo.SmartContract.Testing.Coverage +{ + public partial class NeoDebugInfo + { + [DebuggerDisplay("Name={Name}")] + public struct Method + { + public readonly string Id; + public readonly string Namespace; + public readonly string Name; + public readonly (int Start, int End) Range; + public readonly IReadOnlyList Parameters; + public readonly IReadOnlyList SequencePoints; + + public Method(string id, string @namespace, string name, (int, int) range, IReadOnlyList parameters, IReadOnlyList sequencePoints) + { + Id = id; + Namespace = @namespace; + Name = name; + Range = range; + Parameters = parameters; + SequencePoints = sequencePoints; + } + } + + [DebuggerDisplay("Name={Name}, Type={Type}")] + public struct Parameter + { + public readonly string Name; + public readonly string Type; + public readonly int Index; + + public Parameter(string name, string type, int index) + { + Name = name; + Type = type; + Index = index; + } + } + + [DebuggerDisplay("Document={Document}, Address={Address}")] + public struct SequencePoint + { + public readonly int Address; + public readonly int Document; + public readonly (int Line, int Column) Start; + public readonly (int Line, int Column) End; + + public SequencePoint(int address, int document, (int, int) start, (int, int) end) + { + Address = address; + Document = document; + Start = start; + End = end; + } + } + } +} diff --git a/src/Neo.SmartContract.Testing/Coverage/NeoDebugInfo.cs b/src/Neo.SmartContract.Testing/Coverage/NeoDebugInfo.cs new file mode 100644 index 000000000..fc7bd110e --- /dev/null +++ b/src/Neo.SmartContract.Testing/Coverage/NeoDebugInfo.cs @@ -0,0 +1,269 @@ +using Neo.Json; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Neo.SmartContract.Testing.Coverage +{ + public partial class NeoDebugInfo + { + static readonly Regex spRegex = new(@"^(\d+)\[(-?\d+)\](\d+)\:(\d+)\-(\d+)\:(\d+)$"); + + public const string MANIFEST_FILE_EXTENSION = ".manifest.json"; + public const string NEF_DBG_NFO_EXTENSION = ".nefdbgnfo"; + public const string DEBUG_JSON_EXTENSION = ".debug.json"; + + public readonly UInt160 Hash; + public readonly string DocumentRoot; + public readonly IReadOnlyList Documents; + public readonly IReadOnlyList Methods; + + public NeoDebugInfo(UInt160 hash, string documentRoot, IReadOnlyList documents, IReadOnlyList methods) + { + Hash = hash; + DocumentRoot = documentRoot; + Documents = documents; + Methods = methods; + } + + public static bool TryLoad(string path, [MaybeNullWhen(false)] out NeoDebugInfo debugInfo) + { + if (path.EndsWith(NEF_DBG_NFO_EXTENSION)) + { + return TryLoadCompressed(path, out debugInfo); + } + else if (path.EndsWith(DEBUG_JSON_EXTENSION)) + { + return TryLoadUncompressed(path, out debugInfo); + } + else + { + debugInfo = default; + return false; + } + } + + public static bool TryLoadManifestDebugInfo(string manifestPath, [MaybeNullWhen(false)] out NeoDebugInfo debugInfo) + { + if (string.IsNullOrEmpty(manifestPath)) + { + debugInfo = default; + return false; + } + + var basePath = Path.Combine(Path.GetDirectoryName(manifestPath), GetBaseName(manifestPath, MANIFEST_FILE_EXTENSION)); + + var nefdbgnfoPath = Path.ChangeExtension(basePath, NEF_DBG_NFO_EXTENSION); + if (TryLoadCompressed(nefdbgnfoPath, out debugInfo)) + return true; + + var debugJsonPath = Path.ChangeExtension(basePath, DEBUG_JSON_EXTENSION); + return TryLoadUncompressed(debugJsonPath, out debugInfo); + } + + private static string GetBaseName(string path, string suffix, StringComparison comparison = StringComparison.OrdinalIgnoreCase) + { + if (string.IsNullOrEmpty(path)) + throw new ArgumentNullException(nameof(path)); + path = Path.GetFileName(path); + if (!string.IsNullOrEmpty(suffix) + && path.EndsWith(suffix, comparison)) + { + return path.Substring(0, path.Length - suffix.Length); + } + return path; + } + + static bool TryLoadCompressed(string debugInfoPath, [MaybeNullWhen(false)] out NeoDebugInfo debugInfo) + { + try + { + if (File.Exists(debugInfoPath)) + { + using var fileStream = File.OpenRead(debugInfoPath); + return TryLoadCompressed(fileStream, out debugInfo); + } + } + catch { } + + debugInfo = default; + return false; + } + + internal static bool TryLoadCompressed(Stream stream, [MaybeNullWhen(false)] out NeoDebugInfo debugInfo) + { + try + { + using var zip = new ZipArchive(stream, ZipArchiveMode.Read); + + foreach (var entry in zip.Entries) + { + if (entry.FullName.EndsWith(DEBUG_JSON_EXTENSION, StringComparison.OrdinalIgnoreCase)) + { + using var entryStream = entry.Open(); + debugInfo = Load(entryStream); + return true; + } + } + } + catch { } + + debugInfo = default; + return false; + } + + static bool TryLoadUncompressed(string debugInfoPath, [MaybeNullWhen(false)] out NeoDebugInfo debugInfo) + { + try + { + if (File.Exists(debugInfoPath)) + { + using var fileStream = File.OpenRead(debugInfoPath); + debugInfo = Load(fileStream); + return true; + } + } + catch { } + + debugInfo = default; + return false; + } + + internal static NeoDebugInfo Load(Stream stream) + { + using StreamReader reader = new(stream); + var text = reader.ReadToEnd(); + var json = JToken.Parse(text) ?? throw new InvalidOperationException(); + if (json is not JObject jo) throw new FormatException(); + return FromDebugInfoJson(jo); + } + + public static NeoDebugInfo FromDebugInfoJson(JObject json) + { + if (json["hash"]?.GetString() is not string sHash) + { + throw new ArgumentNullException("hash can't be null"); + } + + if (json["documents"] is not JArray jDocs) + { + throw new ArgumentNullException("documents must be an array"); + } + + if (json["methods"] is not JArray jMethods) + { + throw new ArgumentNullException("methods must be an array"); + } + + var hash = UInt160.TryParse(sHash, out var _hash) + ? _hash + : throw new FormatException($"Invalid hash {sHash}"); + + var docRoot = json["document-root"]?.GetString(); + docRoot = string.IsNullOrEmpty(docRoot) ? "" : docRoot; + + var documents = jDocs.Select(kvp => kvp?.GetString()!).Where(u => u is not null); + var methods = jMethods.Select(kvp => MethodFromJson(kvp as JObject)); + + // TODO: parse events and static variables + + return new NeoDebugInfo(hash, docRoot, documents.ToList(), methods.ToList()); + } + + static Method MethodFromJson(JObject? json) + { + if (json is null) + { + throw new ArgumentNullException("Method can't be null"); + } + + if (json["params"] is not JArray jParams) + { + throw new ArgumentNullException("params must be an array"); + } + + if (json["sequence-points"] is not JArray jSequence) + { + throw new ArgumentNullException("sequence-points must be an array"); + } + + // TODO: parse return, params and variables + + var id = json["id"]?.GetString() ?? throw new ArgumentNullException("method.id can't be null"); + var (@namespace, name) = NameFromJson(json["name"]?.GetString() ?? throw new ArgumentNullException("method.name can't be null")); + var range = RangeFromJson(json["range"]?.GetString() ?? throw new ArgumentNullException("method.range can't be null")); + var @params = jParams.Select(kvp => ParamFromJson(kvp?.GetString())); + var sequencePoints = jSequence.Select(kvp => SequencePointFromJson(kvp?.GetString())); + + return new Method(id, @namespace, name, range, @params.ToList(), sequencePoints.ToList()); + } + + static Parameter ParamFromJson(string? param) + { + if (param is null) + { + throw new ArgumentNullException("Parameter can't be null"); + } + + var values = param.Split(','); + if (values.Length == 2 || values.Length == 3) + { + var index = values.Length == 3 + && int.TryParse(values[2], out var _index) + && _index >= 0 ? _index : -1; + + return new Parameter(values[0], values[1], index); + } + throw new FormatException($"invalid parameter \"{param}\""); + } + + static (string, string) NameFromJson(string? name) + { + if (name is null) + { + throw new ArgumentNullException("Name can't be null"); + } + + var values = name.Split(','); + return values.Length == 2 + ? (values[0], values[1]) + : throw new FormatException($"Invalid name '{name}'"); + } + + static (int, int) RangeFromJson(string? range) + { + if (range is null) + { + throw new ArgumentNullException("Name can't be null"); + } + + var values = range.Split('-'); + return values.Length == 2 + ? (int.Parse(values[0]), int.Parse(values[1])) + : throw new FormatException($"Invalid range '{range}'"); + } + + static SequencePoint SequencePointFromJson(string? sequence) + { + if (sequence is null) + { + throw new ArgumentNullException("Name can't be null"); + } + + var match = spRegex.Match(sequence); + if (match.Groups.Count != 7) + throw new FormatException($"Invalid Sequence Point \"{sequence}\""); + + var address = int.Parse(match.Groups[1].Value); + var document = int.Parse(match.Groups[2].Value); + var start = (int.Parse(match.Groups[3].Value), int.Parse(match.Groups[4].Value)); + var end = (int.Parse(match.Groups[5].Value), int.Parse(match.Groups[6].Value)); + + return new SequencePoint(address, document, start, end); + } + } +} diff --git a/src/Neo.SmartContract.Testing/Neo.SmartContract.Testing.csproj b/src/Neo.SmartContract.Testing/Neo.SmartContract.Testing.csproj index e4af5d8d3..15870ccad 100644 --- a/src/Neo.SmartContract.Testing/Neo.SmartContract.Testing.csproj +++ b/src/Neo.SmartContract.Testing/Neo.SmartContract.Testing.csproj @@ -15,7 +15,8 @@ - + + diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index cc79f094a..88a865e12 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -9,7 +9,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/Neo.SmartContract.Template.UnitTests/Neo.SmartContract.Template.UnitTests.csproj b/tests/Neo.SmartContract.Template.UnitTests/Neo.SmartContract.Template.UnitTests.csproj index 252976257..c5e0f6788 100644 --- a/tests/Neo.SmartContract.Template.UnitTests/Neo.SmartContract.Template.UnitTests.csproj +++ b/tests/Neo.SmartContract.Template.UnitTests/Neo.SmartContract.Template.UnitTests.csproj @@ -18,6 +18,9 @@ PreserveNewest + + PreserveNewest + @@ -28,7 +31,7 @@ - + diff --git a/tests/Neo.SmartContract.Template.UnitTests/templates/neocontractnep17/CoverageContractTests.cs b/tests/Neo.SmartContract.Template.UnitTests/templates/neocontractnep17/CoverageContractTests.cs index b55ad5e80..f7b885bab 100644 --- a/tests/Neo.SmartContract.Template.UnitTests/templates/neocontractnep17/CoverageContractTests.cs +++ b/tests/Neo.SmartContract.Template.UnitTests/templates/neocontractnep17/CoverageContractTests.cs @@ -1,4 +1,6 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; +using Neo.SmartContract.Testing.Coverage; +using Neo.SmartContract.Testing.Coverage.Formats; namespace Neo.SmartContract.Template.UnitTests.templates.neocontractnep17 { @@ -8,7 +10,7 @@ public class CoverageContractTests /// /// Required coverage to be success /// - public static float RequiredCoverage { get; set; } = 1F; + public static decimal RequiredCoverage { get; set; } = 1M; [AssemblyCleanup] public static void EnsureCoverage() @@ -23,7 +25,14 @@ public static void EnsureCoverage() Assert.IsNotNull(coverage); Console.WriteLine(coverage.Dump()); - File.WriteAllText("coverage.html", coverage.Dump(Testing.Coverage.DumpFormat.Html)); + File.WriteAllText("instruction-coverage.html", coverage.Dump(DumpFormat.Html)); + + if (NeoDebugInfo.TryLoad("templates/neocontractnep17/Artifacts/Nep17Contract.nefdbgnfo", out var dbg)) + { + File.WriteAllText("coverage.cobertura.xml", coverage.Dump(new CoberturaFormat((coverage, dbg)))); + CoverageReporting.CreateReport("coverage.cobertura.xml", "./coverageReport/"); + } + Assert.IsTrue(coverage.CoveredLinesPercentage >= RequiredCoverage, $"Coverage is less than {RequiredCoverage:P2}"); } } diff --git a/tests/Neo.SmartContract.Testing.UnitTests/NativeArtifactsTests.cs b/tests/Neo.SmartContract.Testing.UnitTests/NativeArtifactsTests.cs index 4ee79d8ff..92145fc3c 100644 --- a/tests/Neo.SmartContract.Testing.UnitTests/NativeArtifactsTests.cs +++ b/tests/Neo.SmartContract.Testing.UnitTests/NativeArtifactsTests.cs @@ -34,9 +34,9 @@ public void TestInitialize() // Check coverage - Assert.AreEqual(1F, engine.Native.NEO.GetCoverage(o => o.Symbol).CoveredLinesPercentage); - Assert.AreEqual(1F, engine.Native.NEO.GetCoverage(o => o.TotalSupply).CoveredLinesPercentage); - Assert.AreEqual(1F, engine.Native.NEO.GetCoverage(o => o.BalanceOf(It.IsAny())).CoveredLinesPercentage); + Assert.AreEqual(1M, engine.Native.NEO.GetCoverage(o => o.Symbol).CoveredLinesPercentage); + Assert.AreEqual(1M, engine.Native.NEO.GetCoverage(o => o.TotalSupply).CoveredLinesPercentage); + Assert.AreEqual(1M, engine.Native.NEO.GetCoverage(o => o.BalanceOf(It.IsAny())).CoveredLinesPercentage); } [TestMethod]