From 4ef97a2f791bd2b9a76d40bea75df427b159c2c6 Mon Sep 17 00:00:00 2001 From: Yufei Huang Date: Wed, 2 Dec 2020 15:12:53 +0800 Subject: [PATCH 1/3] Update Source --- src/yunit/ITestAttribute.cs | 2 + src/yunit/MarkdownTestAttribute.cs | 6 + src/yunit/TestAdapter.cs | 124 ++++++++++++++++++--- src/yunit/TestData.cs | 15 ++- src/yunit/TestRunResult.cs | 16 +++ src/yunit/YamlTestAttribute.cs | 6 + src/yunit/YamlUtility.cs | 82 +++++++++----- test/yunit.nuget.test/NuGetTest.cs | 10 +- test/yunit.nuget.test/yunit.nuget.test.yml | 14 +++ test/yunit.test/yunit.test.csproj | 4 - yunit.yml | 1 - 11 files changed, 226 insertions(+), 54 deletions(-) create mode 100644 src/yunit/TestRunResult.cs create mode 100644 test/yunit.nuget.test/yunit.nuget.test.yml delete mode 100644 yunit.yml diff --git a/src/yunit/ITestAttribute.cs b/src/yunit/ITestAttribute.cs index 541a328..b1bd1a8 100644 --- a/src/yunit/ITestAttribute.cs +++ b/src/yunit/ITestAttribute.cs @@ -11,6 +11,8 @@ internal interface ITestAttribute string ExpandTest { get; } + bool UpdateSource { get; } + void DiscoverTests(string path, Action report); } } diff --git a/src/yunit/MarkdownTestAttribute.cs b/src/yunit/MarkdownTestAttribute.cs index 597e9a9..b54f490 100644 --- a/src/yunit/MarkdownTestAttribute.cs +++ b/src/yunit/MarkdownTestAttribute.cs @@ -35,6 +35,11 @@ public class MarkdownTestAttribute : Attribute, ITestAttribute /// public string ExpandTest { get; set; } + /// + /// Gets or sets whether the source YAML fragment should be updated if a test returns an object. + /// + public bool UpdateSource { get; set; } + public MarkdownTestAttribute(string glob = null) => Glob = glob; private enum MarkdownReadState @@ -79,6 +84,7 @@ void ITestAttribute.DiscoverTests(string path, Action report) data.Content = content.ToString(); data.Summary = data.Summary.Trim(s_summaryTrimChars); data.FilePath = path; + data.UpdateSource = UpdateSource; report(data); data = new TestData(); state = MarkdownReadState.Markdown; diff --git a/src/yunit/TestAdapter.cs b/src/yunit/TestAdapter.cs index 4d37fb2..e0d6368 100644 --- a/src/yunit/TestAdapter.cs +++ b/src/yunit/TestAdapter.cs @@ -17,11 +17,10 @@ using Microsoft.VisualStudio.TestPlatform.ObjectModel.Adapter; using Microsoft.VisualStudio.TestPlatform.ObjectModel.Logging; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace Yunit { -#pragma warning disable CA1812 // avoid uninstantiated internal classes - // See https://github.com/microsoft/vstest-docs/blob/master/RFCs/0004-Adapter-Extensibility.md // for more details on how to write a vstest adapter [FileExtension(".dll")] @@ -43,7 +42,7 @@ internal class TestAdapter : ITestDiscoverer, ITestExecutor private static readonly TestProperty s_ordinalProperty = TestProperty.Register( "yunit.Ordinal", "Ordinal", typeof(int), TestPropertyAttributes.Hidden, typeof(TestCase)); - private static readonly TestProperty s_MatrixProperty = TestProperty.Register( + private static readonly TestProperty s_matrixProperty = TestProperty.Register( "yunit.Matrix", "Matrix", typeof(string), TestPropertyAttributes.Hidden, typeof(TestCase)); private static readonly TestProperty s_attributeIndexProperty = TestProperty.Register( @@ -97,10 +96,22 @@ void ExpandTest(TestData data) var matrices = InvokeMethod(expandMethodType, expandMethod, data) as IEnumerable; if (matrices != null) { + var first = true; foreach (var matrix in matrices) { var matrixData = data.Clone(); data.Matrix = matrix; + + // Only update source for the first matrix + if (first) + { + first = false; + } + else + { + data.UpdateSource = false; + } + sendTestCase(CreateTestCase(data, type, method, source, i)); } } @@ -114,9 +125,11 @@ void ExpandTest(TestData data) public void RunTests(IEnumerable tests, IRunContext runContext, IFrameworkHandle frameworkHandle) { - var testRuns = new ConcurrentBag(); + var testRuns = new ConcurrentBag>(); Parallel.ForEach(tests, test => testRuns.Add(RunTest(frameworkHandle, test))); - Task.WhenAll(testRuns).GetAwaiter().GetResult(); + + var testResults = Task.WhenAll(testRuns).GetAwaiter().GetResult(); + UpdateSource(testResults); } public void RunTests(IEnumerable sources, IRunContext runContext, IFrameworkHandle frameworkHandle) @@ -139,11 +152,11 @@ public void RunTests(IEnumerable sources, IRunContext runContext, IFrame Task.WhenAll(testRuns).GetAwaiter().GetResult(); } - private async Task RunTest(ITestExecutionRecorder log, TestCase test) + private async Task RunTest(ITestExecutionRecorder log, TestCase test) { if (_canceled) { - return; + return default; } var result = new TestResult(test); @@ -156,24 +169,27 @@ private async Task RunTest(ITestExecutionRecorder log, TestCase test) } result.StartTime = DateTime.UtcNow; - await RunTest(test); - + var returnValue = await RunTest(test); result.Outcome = TestOutcome.Passed; + return returnValue; } catch (TestNotFoundException) { result.Outcome = TestOutcome.NotFound; + return default; } catch (TestSkippedException ex) { result.ErrorMessage = ex.Reason; result.Outcome = TestOutcome.Skipped; + return default; } catch (Exception ex) { result.ErrorMessage = ex.Message; result.ErrorStackTrace = ex.StackTrace; result.Outcome = TestOutcome.Failed; + return default; } finally { @@ -217,7 +233,7 @@ private static TestCase CreateTestCase(TestData data, Type type, MethodInfo meth }; result.SetPropertyValue(s_ordinalProperty, data.Ordinal); - result.SetPropertyValue(s_MatrixProperty, data.Matrix); + result.SetPropertyValue(s_matrixProperty, data.Matrix); result.SetPropertyValue(s_attributeIndexProperty, attributeIndex); return result; @@ -225,13 +241,11 @@ private static TestCase CreateTestCase(TestData data, Type type, MethodInfo meth private static Guid CreateGuid(string displayName) { -#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms using var md5 = SHA1.Create(); var hash = md5.ComputeHash(Encoding.UTF8.GetBytes(displayName)); var buffer = new byte[16]; Array.Copy(hash, 0, buffer, 0, 16); return new Guid(buffer); -#pragma warning restore CA5350 // Do Not Use Weak Cryptographic Algorithms } private static void DiscoverTests(ITestAttribute attribute, string sourcePath, Action report, Action log) @@ -252,7 +266,7 @@ private static void DiscoverTests(ITestAttribute attribute, string sourcePath, A Parallel.ForEach(files, file => attribute.DiscoverTests(Path.Combine(sourcePath, file), report)); } - private Task RunTest(TestCase test) + private async Task RunTest(TestCase test) { if (test.DisplayName.IndexOf("[skip]", 0, StringComparison.OrdinalIgnoreCase) >= 0) { @@ -284,7 +298,7 @@ private Task RunTest(TestCase test) if (data != null) { - data.Matrix = test.GetPropertyValue(s_MatrixProperty, null); + data.Matrix = test.GetPropertyValue(s_matrixProperty, null); } } @@ -293,7 +307,28 @@ private Task RunTest(TestCase test) throw new TestNotFoundException(); } - return InvokeMethod(type, method, data) as Task ?? Task.CompletedTask; + var result = InvokeMethod(type, method, data); + if (result is Task task) + { + await task; + } + + if (!data.UpdateSource) + { + return default; + } + + if (result is Task) + { + result = GetTaskResult(result); + } + + if (result is null) + { + return default; + } + + return GetUpdatedSource(data, result); } private static object InvokeMethod(Type type, MethodInfo method, TestData data) @@ -367,5 +402,64 @@ private static string FindRepositoryPath() return string.IsNullOrEmpty(repo) ? null : repo; } + + private static object GetTaskResult(object task) + { + var type = task.GetType(); + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Task<>)) + { + return type.GetProperty("Result").GetValue(task); + } + return null; + } + + private TestRunResult GetUpdatedSource(TestData data, object result) + { + return new TestRunResult + { + FilePath = data.FilePath, + Lines = YamlUtility.ToString(JToken.FromObject(result)).Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries), + StartLine = data.LineNumber - 1, + LineCount = data.Content.Count(ch => ch == '\n'), + }; + } + + private static void UpdateSource(TestRunResult[] testResults) + { + Parallel.ForEach(testResults.GroupBy(item => item.FilePath), g => UpdateSource(g.Key, g)); + } + + private static void UpdateSource(string filePath, IEnumerable testRunResults) + { + var testIndex = 0; + var lines = File.ReadLines(filePath).ToArray(); + var result = new List(lines.Length); + var orderedTestRuns = testRunResults.OrderBy(test => test.StartLine).ToArray(); + var test = orderedTestRuns[testIndex]; + + for (var i = 0; i < lines.Length;) + { + if (test != null && i == test.StartLine) + { + result.AddRange(test.Lines); + i += test.LineCount; + testIndex++; + if (testIndex >= orderedTestRuns.Length) + { + test = null; + } + else + { + test = orderedTestRuns[testIndex]; + } + } + else + { + result.Add(lines[i++]); + } + } + + File.WriteAllLines(filePath, result); + } } } diff --git a/src/yunit/TestData.cs b/src/yunit/TestData.cs index 8773c64..c02b022 100644 --- a/src/yunit/TestData.cs +++ b/src/yunit/TestData.cs @@ -8,22 +8,22 @@ public class TestData /// /// Gets the absolute path of the declaring file path. /// - public string FilePath { get; set; } + public string FilePath { get; internal set; } /// /// Gets the one based start line number in the declaring file. /// - public int LineNumber { get; set; } + public int LineNumber { get; internal set; } /// /// Gets the one based ordinal in the declaring file. /// - public int Ordinal { get; set; } + public int Ordinal { get; internal set; } /// /// Gets the summary of this data fragment. /// - public string Summary { get; set; } + public string Summary { get; internal set; } /// /// Gets the markdown fenced code tip. E.g. yml for ````yml @@ -33,13 +33,18 @@ public class TestData /// /// Gets the content of this data fragment. /// - public string Content { get; set; } + public string Content { get; internal set; } /// /// Gets the expanded metrix name. /// public string Matrix { get; internal set; } + /// + /// Gets or sets whether the source YAML fragment should be updated if a test returns an object. + /// + public bool UpdateSource { get; internal set; } + internal TestData Clone() { return (TestData)MemberwiseClone(); diff --git a/src/yunit/TestRunResult.cs b/src/yunit/TestRunResult.cs new file mode 100644 index 0000000..017b844 --- /dev/null +++ b/src/yunit/TestRunResult.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Yunit +{ + public class TestRunResult + { + public string FilePath; + + public string[] Lines; + + public int StartLine; + + public int LineCount; + } +} diff --git a/src/yunit/YamlTestAttribute.cs b/src/yunit/YamlTestAttribute.cs index a5345d8..08b2434 100644 --- a/src/yunit/YamlTestAttribute.cs +++ b/src/yunit/YamlTestAttribute.cs @@ -25,6 +25,11 @@ public class YamlTestAttribute : Attribute, ITestAttribute /// public string ExpandTest { get; set; } + /// + /// Gets or sets whether the source YAML fragment should be updated if a test returns an object. + /// + public bool UpdateSource { get; set; } + public YamlTestAttribute(string glob = null) => Glob = glob; void ITestAttribute.DiscoverTests(string path, Action report) @@ -48,6 +53,7 @@ void ITestAttribute.DiscoverTests(string path, Action report) data.Ordinal = ++ordinal; data.Content = content.ToString(); data.FilePath = path; + data.UpdateSource = UpdateSource; report(data); diff --git a/src/yunit/YamlUtility.cs b/src/yunit/YamlUtility.cs index b7ec378..c6d2534 100644 --- a/src/yunit/YamlUtility.cs +++ b/src/yunit/YamlUtility.cs @@ -4,6 +4,7 @@ using System; using System.Globalization; using System.IO; +using System.Text; using Newtonsoft.Json.Linq; using YamlDotNet.Core; @@ -13,74 +14,101 @@ namespace Yunit { internal partial class YamlUtility { - internal static JToken ToJToken( - string input, Action onKeyDuplicate = null, Func onConvert = null) - { - return ToJToken(new StringReader(input), onKeyDuplicate, onConvert); - } - - internal static JToken ToJToken( - TextReader input, Action onKeyDuplicate = null, Func onConvert = null) + public static JToken ToJToken(string input) { JToken result = null; - onKeyDuplicate ??= (_ => { }); - onConvert ??= ((token, _) => token); - - var parser = new Parser(input); + var parser = new Parser(new StringReader(input)); parser.Consume(); if (!parser.TryConsume(out var _)) { parser.Consume(); - result = ToJToken(parser, onKeyDuplicate, onConvert); + result = ToJToken(parser); parser.Consume(); } return result; } - private static JToken ToJToken( - IParser parser, Action onKeyDuplicate, Func onConvert) + private static JToken ToJToken(IParser parser) { switch (parser.Consume()) { case Scalar scalar: if (scalar.Style == ScalarStyle.Plain) { - return onConvert(ParseScalar(scalar.Value), scalar); + return ParseScalar(scalar.Value); } - return onConvert(new JValue(scalar.Value), scalar); + return new JValue(scalar.Value); case SequenceStart seq: var array = new JArray(); while (!parser.TryConsume(out var _)) { - array.Add(ToJToken(parser, onKeyDuplicate, onConvert)); + array.Add(ToJToken(parser)); } - return onConvert(array, seq); + return array; case MappingStart map: var obj = new JObject(); while (!parser.TryConsume(out var _)) { var key = parser.Consume(); - var value = ToJToken(parser, onKeyDuplicate, onConvert); - - if (obj.ContainsKey(key.Value)) - { - onKeyDuplicate(key); - } + var value = ToJToken(parser); obj[key.Value] = value; - onConvert(obj.Property(key.Value), key); + obj.Property(key.Value); } - return onConvert(obj, map); + return obj; default: throw new NotSupportedException($"Yaml node '{parser.Current.GetType().Name}' is not supported"); } } + public static string ToString(JToken token) + { + var result = new StringBuilder(); + var emitter = new Emitter(new StringWriter(result)); + + emitter.Emit(new StreamStart()); + emitter.Emit(new DocumentStart()); + ToString(token, emitter); + emitter.Emit(new DocumentEnd(isImplicit: true)); + emitter.Emit(new StreamEnd()); + + return result.ToString(); + } + + private static void ToString(JToken token, Emitter emitter) + { + switch (token) + { + case JValue value: + emitter.Emit(new Scalar(value.ToString())); + break; + + case JArray arr: + emitter.Emit(new SequenceStart(default, default, default, default)); + foreach (var item in arr) + { + ToString(item, emitter); + } + emitter.Emit(new SequenceEnd()); + break; + + case JObject obj: + emitter.Emit(new MappingStart()); + foreach (var item in obj) + { + ToString(item.Key, emitter); + ToString(item.Value, emitter); + } + emitter.Emit(new MappingEnd()); + break; + } + } + private static JToken ParseScalar(string value) { // https://yaml.org/spec/1.2/2009-07-21/spec.html diff --git a/test/yunit.nuget.test/NuGetTest.cs b/test/yunit.nuget.test/NuGetTest.cs index 064a9e9..a5252b1 100644 --- a/test/yunit.nuget.test/NuGetTest.cs +++ b/test/yunit.nuget.test/NuGetTest.cs @@ -1,6 +1,6 @@ -using System; -using System.IO; +using System.IO; using System.Threading.Tasks; +using Newtonsoft.Json.Linq; namespace Yunit.NuGetTest { @@ -35,5 +35,11 @@ public string[] ExpandTest(string filename) { return new [] { "", "metrix 1", "metrix 2" }; } + + [YamlTest("~/test/yunit.nuget.test/**/*.yml", UpdateSource = true)] + public Task TestUpdateSource(JToken input) + { + return Task.FromResult(input); + } } } diff --git a/test/yunit.nuget.test/yunit.nuget.test.yml b/test/yunit.nuget.test/yunit.nuget.test.yml new file mode 100644 index 0000000..a33bbcb --- /dev/null +++ b/test/yunit.nuget.test/yunit.nuget.test.yml @@ -0,0 +1,14 @@ +--- +# this is a formatted YAML test case +name: my-name +value: +- 1 +- string +--- +# this is another formatted YAML test case +# with multiple lines +str: "string with ' quote" +bool: false +null-value: null +float: 1.0 +int: -1 diff --git a/test/yunit.test/yunit.test.csproj b/test/yunit.test/yunit.test.csproj index fe61a0b..7cb977f 100644 --- a/test/yunit.test/yunit.test.csproj +++ b/test/yunit.test/yunit.test.csproj @@ -4,10 +4,6 @@ netcoreapp3.1 - - - - diff --git a/yunit.yml b/yunit.yml deleted file mode 100644 index e929e34..0000000 --- a/yunit.yml +++ /dev/null @@ -1 +0,0 @@ -# yunit \ No newline at end of file From 0bfe1dc0d1bcec5fa34b21e717370fb0195436ee Mon Sep 17 00:00:00 2001 From: Yufei Huang Date: Wed, 2 Dec 2020 15:28:33 +0800 Subject: [PATCH 2/3] Improve formatting --- src/yunit/MarkdownTestAttribute.cs | 15 ++++++++++++--- src/yunit/TestAdapter.cs | 6 +++--- src/yunit/TestData.cs | 5 +++++ src/yunit/TestRunResult.cs | 2 +- src/yunit/YamlTestAttribute.cs | 19 ++++++++++++++----- src/yunit/YamlUtility.cs | 15 ++++++++++++++- test/yunit.nuget.test/yunit.nuget.test.yml | 4 ++-- 7 files changed, 51 insertions(+), 15 deletions(-) diff --git a/src/yunit/MarkdownTestAttribute.cs b/src/yunit/MarkdownTestAttribute.cs index b54f490..274302d 100644 --- a/src/yunit/MarkdownTestAttribute.cs +++ b/src/yunit/MarkdownTestAttribute.cs @@ -91,9 +91,18 @@ void ITestAttribute.DiscoverTests(string path, Action report) break; case MarkdownReadState.Fence: - if (line.Length > indent) - content.Append(line, indent, line.Length - indent); - content.AppendLine(); + if (!line.StartsWith("#")) + { + if (content.Length == 0) + { + data.ContentStartLine = lineNumber; + } + if (line.Length > indent) + { + content.Append(line, indent, line.Length - indent); + } + content.AppendLine(); + } break; } } diff --git a/src/yunit/TestAdapter.cs b/src/yunit/TestAdapter.cs index e0d6368..078a637 100644 --- a/src/yunit/TestAdapter.cs +++ b/src/yunit/TestAdapter.cs @@ -419,7 +419,7 @@ private TestRunResult GetUpdatedSource(TestData data, object result) { FilePath = data.FilePath, Lines = YamlUtility.ToString(JToken.FromObject(result)).Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries), - StartLine = data.LineNumber - 1, + ContentStartLine = data.ContentStartLine, LineCount = data.Content.Count(ch => ch == '\n'), }; } @@ -434,12 +434,12 @@ private static void UpdateSource(string filePath, IEnumerable tes var testIndex = 0; var lines = File.ReadLines(filePath).ToArray(); var result = new List(lines.Length); - var orderedTestRuns = testRunResults.OrderBy(test => test.StartLine).ToArray(); + var orderedTestRuns = testRunResults.OrderBy(test => test.ContentStartLine).ToArray(); var test = orderedTestRuns[testIndex]; for (var i = 0; i < lines.Length;) { - if (test != null && i == test.StartLine) + if (test != null && i == test.ContentStartLine - 1) { result.AddRange(test.Lines); i += test.LineCount; diff --git a/src/yunit/TestData.cs b/src/yunit/TestData.cs index c02b022..8217e4e 100644 --- a/src/yunit/TestData.cs +++ b/src/yunit/TestData.cs @@ -35,6 +35,11 @@ public class TestData /// public string Content { get; internal set; } + /// + /// Gets the one based start line in the declaring file. + /// + public int ContentStartLine { get; internal set; } + /// /// Gets the expanded metrix name. /// diff --git a/src/yunit/TestRunResult.cs b/src/yunit/TestRunResult.cs index 017b844..e506a79 100644 --- a/src/yunit/TestRunResult.cs +++ b/src/yunit/TestRunResult.cs @@ -9,7 +9,7 @@ public class TestRunResult public string[] Lines; - public int StartLine; + public int ContentStartLine; public int LineCount; } diff --git a/src/yunit/YamlTestAttribute.cs b/src/yunit/YamlTestAttribute.cs index 08b2434..bfd7267 100644 --- a/src/yunit/YamlTestAttribute.cs +++ b/src/yunit/YamlTestAttribute.cs @@ -68,13 +68,22 @@ void ITestAttribute.DiscoverTests(string path, Action report) } else { - if (line.StartsWith("#") && data.Summary is null) + if (line.StartsWith("#")) { - data.Summary = line.Trim(s_summaryTrimChars); + if (data.Summary is null) + { + data.Summary = line.Trim(s_summaryTrimChars); + } + } + else + { + if (content.Length == 0) + { + data.ContentStartLine = lineNumber; + } + content.Append(line); + content.AppendLine(); } - - content.Append(line); - content.AppendLine(); } } } diff --git a/src/yunit/YamlUtility.cs b/src/yunit/YamlUtility.cs index c6d2534..e067dc8 100644 --- a/src/yunit/YamlUtility.cs +++ b/src/yunit/YamlUtility.cs @@ -85,7 +85,7 @@ private static void ToString(JToken token, Emitter emitter) switch (token) { case JValue value: - emitter.Emit(new Scalar(value.ToString())); + emitter.Emit(new Scalar(ScalarToString(value))); break; case JArray arr: @@ -156,5 +156,18 @@ private static JToken ParseScalar(string value) } return new JValue(value); } + + private static string ScalarToString(JValue value) + { + return value.Value switch + { + null => "null", + bool b => b ? "true" : "false", + double d when double.IsNaN(d) => ".nan", + double d when double.IsPositiveInfinity(d) => ".inf", + double d when double.IsNegativeInfinity(d) => "-.inf", + _ => value.ToString(), + }; + } } } diff --git a/test/yunit.nuget.test/yunit.nuget.test.yml b/test/yunit.nuget.test/yunit.nuget.test.yml index a33bbcb..e6bd257 100644 --- a/test/yunit.nuget.test/yunit.nuget.test.yml +++ b/test/yunit.nuget.test/yunit.nuget.test.yml @@ -7,8 +7,8 @@ value: --- # this is another formatted YAML test case # with multiple lines -str: "string with ' quote" +str: '> string needs quote' bool: false null-value: null -float: 1.0 +float: 3.1415926 int: -1 From c26a14c99886cdea656d09c0aea33dae931c688a Mon Sep 17 00:00:00 2001 From: Yufei Huang Date: Wed, 2 Dec 2020 15:34:29 +0800 Subject: [PATCH 3/3] Update dotnetcore.yml --- .github/workflows/dotnetcore.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index 52f46e4..649c543 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -8,7 +8,7 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v1 with: - dotnet-version: 3.0.100 + dotnet-version: 3.1.404 - name: Build and Test run: ./build.ps1 - name: Publish NuGet Package