diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 8219c0f..b330bad 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -5,9 +5,7 @@ name: compile and test on: push: - branches: [ "main" ] pull_request: - branches: [ "main" ] jobs: test: diff --git a/.gitignore b/.gitignore index 36bd81a..62534d2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ obj/ Playground/ /MSBuildWasmLocal.sln /examples/go -templates/src \ No newline at end of file +templates/src +/test/WasmTasksTests/GeneratedRustTests diff --git a/src/WasmTask.cs b/src/WasmTask.cs index aae5012..cb27609 100644 --- a/src/WasmTask.cs +++ b/src/WasmTask.cs @@ -301,11 +301,11 @@ private void ReflectOutputJsonToClassProperties(string taskOutputJson) } catch (JsonException ex) { - Log.LogError($"Error parsing JSON: {ex.Message}"); + Log.LogError($"Error parsing output JSON: {ex.Message}"); } catch (Exception ex) { - Log.LogError($"Error Reflecting properties from Json to Class: {ex.Message}"); + Log.LogError($"Error Reflecting properties from Json to Class after task run: {ex.Message}"); } } diff --git a/src/WasmTaskFactory.cs b/src/WasmTaskFactory.cs index b48614f..b84cb4d 100644 --- a/src/WasmTaskFactory.cs +++ b/src/WasmTaskFactory.cs @@ -15,10 +15,12 @@ namespace MSBuildWasm /// public class WasmTaskFactory : ITaskFactory2 { - // TODO avoid hardcoded when possible + /// + /// Name displayed in the MSBuild log. + /// public string FactoryName => nameof(WasmTaskFactory); + public TaskLoggingHelper Log { get; set; } private TaskPropertyInfo[] _taskProperties; - private TaskLoggingHelper _log; private bool _taskInfoReceived = false; private string _taskName; private string _taskPath; @@ -45,7 +47,7 @@ public TaskPropertyInfo[] GetTaskParameters() public bool Initialize(string taskName, IDictionary factoryIdentityParameters, IDictionary parameterGroup, string taskBody, IBuildEngine taskFactoryLoggingHost) { - _log = new TaskLoggingHelper(taskFactoryLoggingHost, taskName) + Log = new TaskLoggingHelper(taskFactoryLoggingHost, taskName) { HelpKeywordPrefix = $"WasmTask.{taskName}." }; @@ -74,7 +76,7 @@ private void GetCustomWasmTaskProperties() { using var engine = new Engine(); using var module = Wasmtime.Module.FromFile(engine, _taskPath); - using var linker = new WasmTaskLinker(engine, _log); + using var linker = new WasmTaskLinker(engine, Log); using var store = new Store(engine); linker.DefineWasi(); linker.LinkLogFunctions(store); @@ -84,7 +86,7 @@ private void GetCustomWasmTaskProperties() Action getTaskInfo = instance.GetAction(WasmTask.GetTaskInfoFunctionName); if (getTaskInfo == null) { - _log.LogError("Function 'GetTaskInfo' not found in the WebAssembly module."); + Log.LogError("Function 'GetTaskInfo' not found in the WebAssembly module."); return; } @@ -92,11 +94,11 @@ private void GetCustomWasmTaskProperties() } catch (WasmtimeException ex) { - _log.LogErrorFromException(ex, true); + Log.LogErrorFromException(ex, true); } if (!_taskInfoReceived) { - _log.LogError("Task info was not received from the WebAssembly module."); + Log.LogError("Task info was not received from the WebAssembly module."); } } @@ -104,15 +106,19 @@ private void GetCustomWasmTaskProperties() /// Handling callback with Task Info JSON. /// /// WASIp2: get structured info from GetTaskInfo function output and parse it via classes from wit-bindgen and convert them to properties, this event scheme unnecessary - private void OnTaskInfoReceived(object sender, string taskInfoJson) + internal void OnTaskInfoReceived(object sender, string taskInfoJson) { try { _taskProperties = Serializer.DeserializeTaskInfoJson(taskInfoJson); } - catch (Exception ex) when (ex is JsonException || ex is KeyNotFoundException || ex is ArgumentException) + catch (Exception ex) when (ex is JsonException || ex is KeyNotFoundException || ex is ArgumentException || ex is InvalidOperationException) + { + Log.LogError("Could not deserialize Task Info JSON. {0}:{1}", ex.GetType().ToString(), ex.Message); + } + catch (Exception ex) { - _log.LogErrorFromException(ex); + Log.LogError("Unknown error in Task Info JSON deserialization. {0}", ex.Message); } _taskInfoReceived = true; } diff --git a/test/WasmTasksTests/WasmTaskFactory_Tests.cs b/test/WasmTasksTests/WasmTaskFactory_Tests.cs index c6ce1d9..c1074ed 100644 --- a/test/WasmTasksTests/WasmTaskFactory_Tests.cs +++ b/test/WasmTasksTests/WasmTaskFactory_Tests.cs @@ -50,29 +50,47 @@ public void BuildTaskType_CreatesTypeWithCorrectProperties(Type propType) Assert.NotNull(resultType.GetProperty(prop1name)); Assert.Null(resultType.GetProperty(prop2name)); } - // split/theory - [Fact] - public void BuildTaskType_CreatesTypeWithCorrectAttributes() + + [Theory] + [InlineData("StringProp", typeof(string), true, false)] + [InlineData("BoolProp", typeof(bool), false, true)] + [InlineData("TaskItemProp", typeof(ITaskItem), false, false)] + [InlineData("StringArrayProp", typeof(string[]), true, true)] + public void BuildTaskType_CreatesTypeWithCorrectAttributes(string propertyName, Type propertyType, bool isOutput, bool isRequired) { // Arrange const string taskName = "TestTask"; - const string prop1name = "p1"; - const string prop2name = "p2"; TaskPropertyInfo[] properties = new[] { - new TaskPropertyInfo(prop1name, typeof(string), output: true, required: false), - new TaskPropertyInfo(prop2name, typeof(bool), false, true) - }; + new TaskPropertyInfo(propertyName, propertyType, isOutput, isRequired) + }; // Act Type resultType = WasmTaskReflectionBuilder.BuildTaskType(taskName, properties); // Assert - Assert.NotNull(resultType.GetProperty(prop1name)!.GetCustomAttribute()); - Assert.Null(resultType.GetProperty(prop1name)!.GetCustomAttribute()); - Assert.NotNull(resultType.GetProperty(prop2name)!.GetCustomAttribute()); - Assert.Null(resultType.GetProperty(prop2name)!.GetCustomAttribute()); + PropertyInfo? property = resultType.GetProperty(propertyName); + Assert.NotNull(property); + + if (isOutput) + { + Assert.NotNull(property!.GetCustomAttribute()); + } + else + { + Assert.Null(property!.GetCustomAttribute()); + } + + if (isRequired) + { + Assert.NotNull(property!.GetCustomAttribute()); + } + else + { + Assert.Null(property!.GetCustomAttribute()); + } } + [Fact] public void ConvertJsonTaskInfoToProperties_ShouldParseProperties() { @@ -90,16 +108,21 @@ public void ConvertJsonTaskInfoToProperties_ShouldParseProperties() propsExpected.ShouldBeEquivalentTo(propsParsed); } - // the task returns undeserializable json, should error - //[Fact] - //public void GetTaskInfo_InvalidJson_ShouldError() - //{ - // const string invalidJson = "{ \"Properties\": { \"Dirs\": { \"type\": \"ITaskItem[]\", \"required\": true, \"output\": false }, \"MergedDir\": { \"type\": \"ITaskItem\", \"required\": false, \"output\": true }, \"MergedName\": { \"type\": \"string\", \"required\": false, \"output\": false } "; + [Fact] + public void GetTaskInfo_InvalidJson_ShouldError() + { + const string invalidJson = "{ \"Properties\": { \"Dirs\": { \"type\": \"ITaskItem[]\", \"required\": true, \"output\": false }, \"MergedDir\": { \"type\": \"ITaskItem\", \"required\": false, \"output\": true }, \"MergedName\": { \"type\": \"string\", \"required\": false, \"output\": false {} "; - // WasmTaskFactory factory = new WasmTaskFactory(); + MockEngine m = new MockEngine(); - // factory.OnTaskInfoReceived(null, invalidJson); + WasmTaskFactory factory = new WasmTaskFactory + { + Log = new Microsoft.Build.Utilities.TaskLoggingHelper(m, "TestTask") + }; + factory.OnTaskInfoReceived(null, invalidJson); + m.AssertLogContains("Error"); + } diff --git a/test/WasmTasksTests/WasmTask_Tests.cs b/test/WasmTasksTests/WasmTask_Tests.cs index 3aceed1..e72bdaa 100644 --- a/test/WasmTasksTests/WasmTask_Tests.cs +++ b/test/WasmTasksTests/WasmTask_Tests.cs @@ -13,6 +13,7 @@ namespace WasmTasksTests public class WasmTask_Tests : IDisposable { private const string SOLUTION_ROOT_PATH = "../../../../../"; + private const string TEST_PROJECT_PATH = "../../../"; private const string WASM_RUST_TARGET_PATH = "target/wasm32-wasi/release/"; private static readonly string[] s_names = ["rust_template", "rust_concat2files", "rust_mergedirectories"]; private static readonly string[] s_paths = [$"templates/content/RustWasmTaskTemplate/{s_names[0]}/", $"examples/{s_names[1]}/", $"examples/{s_names[2]}/"]; @@ -160,42 +161,220 @@ private static void ExecuteCommand(string command) Console.WriteLine($"Exception: {ex.Message}"); } } - public class TemplateWasmTask : WasmTask - { - public TemplateWasmTask() : base() + public class TemplateWasmTask : WasmTask { - WasmFilePath = WasmTask_Tests.s_templateFilePath; - BuildEngine = new MockEngine(); + public TemplateWasmTask() : base() + { + WasmFilePath = WasmTask_Tests.s_templateFilePath; + BuildEngine = new MockEngine(); + } } - } - public class ConcatWasmTask : WasmTask - { - public ITaskItem? InputFile1 { get; set; } - public ITaskItem? InputFile2 { get; set; } - [Output] - public ITaskItem? OutputFile { get; set; } + public class ConcatWasmTask : WasmTask + { + public ITaskItem? InputFile1 { get; set; } + public ITaskItem? InputFile2 { get; set; } + [Output] + public ITaskItem? OutputFile { get; set; } - public ConcatWasmTask() : base() + public ConcatWasmTask() : base() + { + WasmFilePath = WasmTask_Tests.s_concatFilePath; + BuildEngine = new MockEngine(); + } + } + + public class DirectoryMergeWasmTask : WasmTask { - WasmFilePath = WasmTask_Tests.s_concatFilePath; - BuildEngine = new MockEngine(); + public ITaskItem[]? Dirs { get; set; } + public string? MergedName { get; set; } + [Output] + public ITaskItem? MergedDir { get; set; } + + public DirectoryMergeWasmTask() : base() + { + WasmFilePath = WasmTask_Tests.s_mergeFilePath; + BuildEngine = new MockEngine(); + } } - } + private class CustomRustE2ETest + { + private readonly string _name; + private readonly string _templatePath; + private readonly string _testProjectPath; - public class DirectoryMergeWasmTask : WasmTask - { - public ITaskItem[]? Dirs { get; set; } - public string? MergedName { get; set; } - [Output] - public ITaskItem? MergedDir { get; set; } + public CustomRustE2ETest(string name, string toplevel, string executeRustCode, string getTaskInfoRustCode) + { + _name = name; + _templatePath = Path.Combine(SOLUTION_ROOT_PATH, s_paths[0]); + _testProjectPath = Path.Combine(TEST_PROJECT_PATH, "GeneratedRustTests", _name); + + CopyTemplate(); + CreateLibRs(toplevel, executeRustCode, getTaskInfoRustCode); + Compile(); + } + + private void CopyTemplate() + { + if (Directory.Exists(_testProjectPath)) + { + Directory.Delete(_testProjectPath, true); + } + DirectoryCopy(_templatePath, _testProjectPath, true); + } + + private void CreateLibRs(string topLevel, string execute, string getTaskInfo) + { + string contents = $@" +mod msbuild; +use msbuild::logging::{{log_warning, log_error, log_message}}; +use msbuild::task_info::{{task_info, Property, PropertyType, TaskInfoStruct, TaskResult}}; +use serde::{{Serialize, Deserialize}}; +{topLevel} + +#[no_mangle] +#[allow(non_snake_case)] +pub fn Execute() -> TaskResult +{{ +{execute} +}} + +#[no_mangle] +#[allow(non_snake_case)] +pub fn GetTaskInfo() +{{ +{getTaskInfo} +}} +"; + File.WriteAllText(Path.Combine(_testProjectPath, "src", "lib.rs"), contents); + } + + private void Compile() + { + string cargo_toml = Path.Combine(_testProjectPath, "Cargo.toml"); + ExecuteCommand($"cargo build --release --target wasm32-wasi --manifest-path {cargo_toml}"); + } + + public string GetWasmFilePath() + { + return Path.Combine(_testProjectPath, WASM_RUST_TARGET_PATH, $"rust_template.wasm"); + } - public DirectoryMergeWasmTask() : base() + private static void DirectoryCopy(string sourceDirName, string destDirName, bool copySubDirs) + { + DirectoryInfo dir = new DirectoryInfo(sourceDirName); + DirectoryInfo[] dirs = dir.GetDirectories(); + + if (!Directory.Exists(destDirName)) + { + Directory.CreateDirectory(destDirName); + } + + FileInfo[] files = dir.GetFiles(); + foreach (FileInfo file in files) + { + string tempPath = Path.Combine(destDirName, file.Name); + file.CopyTo(tempPath, false); + } + + if (copySubDirs) + { + foreach (DirectoryInfo subdir in dirs) + { + string tempPath = Path.Combine(destDirName, subdir.Name); + DirectoryCopy(subdir.FullName, tempPath, copySubDirs); + } + } + } + } + + private class EmptyWasmTask : WasmTask { - WasmFilePath = WasmTask_Tests.s_mergeFilePath; - BuildEngine = new MockEngine(); + public EmptyWasmTask(string path) : base() + { + WasmFilePath = path; + BuildEngine = new MockEngine(); + } } - } + + // what happens if the rust tries to log invalid strings + // note that when there is an error in the test, the template .wasm is used leading to unclear messages + [Theory] + [InlineData("invmem_nullptr", "std::ptr::null()", "0", false)] + [InlineData("invmem_nullptr2", "std::ptr::null()", "10", false)] + [InlineData("invmem_longer", "c_str.as_ptr()", "1000", false)] + [InlineData("invmem_toobigptr", "usize::MAX as *const i8", "200", true)] + [InlineData("invmem_maxlen", "c_str.as_ptr()", "usize::MAX", true)] + public void LogInvalidMemory(string name, string memAddress, string len, bool shouldError) + { + var e2e = new CustomRustE2ETest(name, @"use msbuild::logging::{{LogWarning}};" + , $@" +let invalid_json = ""{{\""key\"": \""value\""""; +let c_str = std::ffi::CString::new(invalid_json).unwrap(); +unsafe{{ +LogWarning({memAddress},{len}) +}} +println!(""{{}}"", ""{{\""properties\"":{{}}}}""); +TaskResult::Success +", ""); + + var task = new EmptyWasmTask(e2e.GetWasmFilePath()); + task.Execute(); + + + if (shouldError) + { + task.Log.HasLoggedErrors.ShouldBeTrue(); + } + else + { + task.Log.HasLoggedErrors.ShouldBeFalse(); + } + } + + private class OPropTask : WasmTask + { + [Output] + public string? OProp { get; set; } + + public OPropTask(string wasmPath) : base() + { + WasmFilePath = wasmPath; + BuildEngine = new MockEngine(); + } + } + + [Theory] + [InlineData("invout_nothing", "")] + [InlineData("invout_empty", "{}")] + [InlineData("invout_invalidjson", "{{}")] + [InlineData("invout_integerprop", @"{\""properties\"":{\""OProp\"":11}}")] + [InlineData("invout_dictprop", @"{\""properties\"":{\""OProp\"":{\""a\"":\""b\""}}}")] + [InlineData("invout_arrayprop", @"{\""properties\"":{\""OProp\"":[{\""a\"":\""b\""}]}}")] //sus + [InlineData("invout_unspecifiedprop", @"{\""properties\"":{\""OProp\"":{}}}")] + public void InvalidOutputShouldError(string name, string output) + { + var e2e = new CustomRustE2ETest(name, "", @$"println!(""{{}}"", ""{output}"");TaskResult::Success", @" + let task_info_struct = TaskInfoStruct { + name: String::from(""testtest""), + properties: vec![ + Property { + name: String::from(""OProp""), + output: true, + required: false, + property_type: PropertyType::String, + }, + ], + }; + task_info(task_info_struct); +"); + + var task = new OPropTask(e2e.GetWasmFilePath()); + task.Execute(); + task.Log.HasLoggedErrors.ShouldBeTrue(); + + } + } }