From 15cbadb4af5499072564f48f709da888f860121c Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Mon, 26 Sep 2022 10:17:46 +0100 Subject: [PATCH] Add file commands for save-state and set-output (#2118) --- src/Runner.Common/ExtensionManager.cs | 2 + src/Runner.Worker/FileCommandManager.cs | 308 ++++++++----- src/Runner.Worker/GitHubContext.cs | 2 + src/Test/L0/Worker/SaveStateFileCommandL0.cs | 438 ++++++++++++++++++ src/Test/L0/Worker/SetOutputFileCommandL0.cs | 444 +++++++++++++++++++ 5 files changed, 1085 insertions(+), 109 deletions(-) create mode 100644 src/Test/L0/Worker/SaveStateFileCommandL0.cs create mode 100644 src/Test/L0/Worker/SetOutputFileCommandL0.cs diff --git a/src/Runner.Common/ExtensionManager.cs b/src/Runner.Common/ExtensionManager.cs index c7fbab73bcd..6f1bfc5d7b6 100644 --- a/src/Runner.Common/ExtensionManager.cs +++ b/src/Runner.Common/ExtensionManager.cs @@ -60,6 +60,8 @@ private List LoadExtensions() where T : class, IExtension Add(extensions, "GitHub.Runner.Worker.AddPathFileCommand, Runner.Worker"); Add(extensions, "GitHub.Runner.Worker.SetEnvFileCommand, Runner.Worker"); Add(extensions, "GitHub.Runner.Worker.CreateStepSummaryCommand, Runner.Worker"); + Add(extensions, "GitHub.Runner.Worker.SaveStateFileCommand, Runner.Worker"); + Add(extensions, "GitHub.Runner.Worker.SetOutputFileCommand, Runner.Worker"); break; case "GitHub.Runner.Listener.Check.ICheckExtension": Add(extensions, "GitHub.Runner.Listener.Check.InternetCheck, Runner.Listener"); diff --git a/src/Runner.Worker/FileCommandManager.cs b/src/Runner.Worker/FileCommandManager.cs index 37aacfbe60a..66362f6812a 100644 --- a/src/Runner.Worker/FileCommandManager.cs +++ b/src/Runner.Worker/FileCommandManager.cs @@ -138,74 +138,10 @@ public sealed class SetEnvFileCommand : RunnerService, IFileCommandExtension public void ProcessCommand(IExecutionContext context, string filePath, ContainerInfo container) { - try - { - var text = File.ReadAllText(filePath) ?? string.Empty; - var index = 0; - var line = ReadLine(text, ref index); - while (line != null) - { - if (!string.IsNullOrEmpty(line)) - { - var equalsIndex = line.IndexOf("=", StringComparison.Ordinal); - var heredocIndex = line.IndexOf("<<", StringComparison.Ordinal); - - // Normal style NAME=VALUE - if (equalsIndex >= 0 && (heredocIndex < 0 || equalsIndex < heredocIndex)) - { - var split = line.Split(new[] { '=' }, 2, StringSplitOptions.None); - if (string.IsNullOrEmpty(line)) - { - throw new Exception($"Invalid environment variable format '{line}'. Environment variable name must not be empty"); - } - SetEnvironmentVariable(context, split[0], split[1]); - } - // Heredoc style NAME<= 0 && (equalsIndex < 0 || heredocIndex < equalsIndex)) - { - var split = line.Split(new[] { "<<" }, 2, StringSplitOptions.None); - if (string.IsNullOrEmpty(split[0]) || string.IsNullOrEmpty(split[1])) - { - throw new Exception($"Invalid environment variable format '{line}'. Environment variable name must not be empty and delimiter must not be empty"); - } - var name = split[0]; - var delimiter = split[1]; - var startIndex = index; // Start index of the value (inclusive) - var endIndex = index; // End index of the value (exclusive) - var tempLine = ReadLine(text, ref index, out var newline); - while (!string.Equals(tempLine, delimiter, StringComparison.Ordinal)) - { - if (tempLine == null) - { - throw new Exception($"Invalid environment variable value. Matching delimiter not found '{delimiter}'"); - } - if (newline == null) - { - throw new Exception($"Invalid environment variable value. EOF marker missing new line."); - } - endIndex = index - newline.Length; - tempLine = ReadLine(text, ref index, out newline); - } - - var value = endIndex > startIndex ? text.Substring(startIndex, endIndex - startIndex) : string.Empty; - SetEnvironmentVariable(context, name, value); - } - else - { - throw new Exception($"Invalid environment variable format '{line}'"); - } - } - - line = ReadLine(text, ref index); - } - } - catch (DirectoryNotFoundException) + var pairs = new EnvFileKeyValuePairs(context, filePath); + foreach (var pair in pairs) { - context.Debug($"Environment variables file does not exist '{filePath}'"); - } - catch (FileNotFoundException) - { - context.Debug($"Environment variables file does not exist '{filePath}'"); + SetEnvironmentVariable(context, pair.Key, pair.Value); } } @@ -218,48 +154,6 @@ private static void SetEnvironmentVariable( context.SetEnvContext(name, value); context.Debug($"{name}='{value}'"); } - - private static string ReadLine( - string text, - ref int index) - { - return ReadLine(text, ref index, out _); - } - - private static string ReadLine( - string text, - ref int index, - out string newline) - { - if (index >= text.Length) - { - newline = null; - return null; - } - - var originalIndex = index; - var lfIndex = text.IndexOf("\n", index, StringComparison.Ordinal); - if (lfIndex < 0) - { - index = text.Length; - newline = null; - return text.Substring(originalIndex); - } - -#if OS_WINDOWS - var crLFIndex = text.IndexOf("\r\n", index, StringComparison.Ordinal); - if (crLFIndex >= 0 && crLFIndex < lfIndex) - { - index = crLFIndex + 2; // Skip over CRLF - newline = "\r\n"; - return text.Substring(originalIndex, crLFIndex - originalIndex); - } -#endif - - index = lfIndex + 1; // Skip over LF - newline = "\n"; - return text.Substring(originalIndex, lfIndex - originalIndex); - } } public sealed class CreateStepSummaryCommand : RunnerService, IFileCommandExtension @@ -325,4 +219,200 @@ public void ProcessCommand(IExecutionContext context, string filePath, Container } } } + + public sealed class SaveStateFileCommand : RunnerService, IFileCommandExtension + { + public string ContextName => "state"; + public string FilePrefix => "save_state_"; + + public Type ExtensionType => typeof(IFileCommandExtension); + + public void ProcessCommand(IExecutionContext context, string filePath, ContainerInfo container) + { + var pairs = new EnvFileKeyValuePairs(context, filePath); + foreach (var pair in pairs) + { + // Embedded steps (composite) keep track of the state at the root level + if (context.IsEmbedded) + { + var id = context.EmbeddedId; + if (!context.Root.EmbeddedIntraActionState.ContainsKey(id)) + { + context.Root.EmbeddedIntraActionState[id] = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + context.Root.EmbeddedIntraActionState[id][pair.Key] = pair.Value; + } + // Otherwise modify the ExecutionContext + else + { + context.IntraActionState[pair.Key] = pair.Value; + } + + context.Debug($"Save intra-action state {pair.Key} = {pair.Value}"); + } + } + } + + public sealed class SetOutputFileCommand : RunnerService, IFileCommandExtension + { + public string ContextName => "output"; + public string FilePrefix => "set_output_"; + + public Type ExtensionType => typeof(IFileCommandExtension); + + public void ProcessCommand(IExecutionContext context, string filePath, ContainerInfo container) + { + var pairs = new EnvFileKeyValuePairs(context, filePath); + foreach (var pair in pairs) + { + context.SetOutput(pair.Key, pair.Value, out var reference); + context.Debug($"Set output {pair.Key} = {pair.Value}"); + } + } + } + + public sealed class EnvFileKeyValuePairs: IEnumerable> + { + private IExecutionContext _context; + private string _filePath; + + public EnvFileKeyValuePairs(IExecutionContext context, string filePath) + { + _context = context; + _filePath = filePath; + } + + public IEnumerator> GetEnumerator() + { + var text = string.Empty; + try + { + text = File.ReadAllText(_filePath) ?? string.Empty; + } + catch (DirectoryNotFoundException) + { + _context.Debug($"File does not exist '{_filePath}'"); + yield break; + } + catch (FileNotFoundException) + { + _context.Debug($"File does not exist '{_filePath}'"); + yield break; + } + + var index = 0; + var line = ReadLine(text, ref index); + while (line != null) + { + if (!string.IsNullOrEmpty(line)) + { + var key = string.Empty; + var output = string.Empty; + + var equalsIndex = line.IndexOf("=", StringComparison.Ordinal); + var heredocIndex = line.IndexOf("<<", StringComparison.Ordinal); + + // Normal style NAME=VALUE + if (equalsIndex >= 0 && (heredocIndex < 0 || equalsIndex < heredocIndex)) + { + var split = line.Split(new[] { '=' }, 2, StringSplitOptions.None); + if (string.IsNullOrEmpty(line)) + { + throw new Exception($"Invalid format '{line}'. Name must not be empty"); + } + + key = split[0]; + output = split[1]; + } + + // Heredoc style NAME<= 0 && (equalsIndex < 0 || heredocIndex < equalsIndex)) + { + var split = line.Split(new[] { "<<" }, 2, StringSplitOptions.None); + if (string.IsNullOrEmpty(split[0]) || string.IsNullOrEmpty(split[1])) + { + throw new Exception($"Invalid format '{line}'. Name must not be empty and delimiter must not be empty"); + } + key = split[0]; + var delimiter = split[1]; + var startIndex = index; // Start index of the value (inclusive) + var endIndex = index; // End index of the value (exclusive) + var tempLine = ReadLine(text, ref index, out var newline); + while (!string.Equals(tempLine, delimiter, StringComparison.Ordinal)) + { + if (tempLine == null) + { + throw new Exception($"Invalid value. Matching delimiter not found '{delimiter}'"); + } + if (newline == null) + { + throw new Exception($"Invalid value. EOF marker missing new line."); + } + endIndex = index - newline.Length; + tempLine = ReadLine(text, ref index, out newline); + } + + output = endIndex > startIndex ? text.Substring(startIndex, endIndex - startIndex) : string.Empty; + } + else + { + throw new Exception($"Invalid format '{line}'"); + } + + yield return new KeyValuePair(key, output); + } + + line = ReadLine(text, ref index); + } + } + + System.Collections.IEnumerator + System.Collections.IEnumerable.GetEnumerator() + { + // Invoke IEnumerator> GetEnumerator() above. + return GetEnumerator(); + } + + private static string ReadLine( + string text, + ref int index) + { + return ReadLine(text, ref index, out _); + } + + private static string ReadLine( + string text, + ref int index, + out string newline) + { + if (index >= text.Length) + { + newline = null; + return null; + } + + var originalIndex = index; + var lfIndex = text.IndexOf("\n", index, StringComparison.Ordinal); + if (lfIndex < 0) + { + index = text.Length; + newline = null; + return text.Substring(originalIndex); + } + +#if OS_WINDOWS + var crLFIndex = text.IndexOf("\r\n", index, StringComparison.Ordinal); + if (crLFIndex >= 0 && crLFIndex < lfIndex) + { + index = crLFIndex + 2; // Skip over CRLF + newline = "\r\n"; + return text.Substring(originalIndex, crLFIndex - originalIndex); + } +#endif + + index = lfIndex + 1; // Skip over LF + newline = "\n"; + return text.Substring(originalIndex, lfIndex - originalIndex); + } + } } diff --git a/src/Runner.Worker/GitHubContext.cs b/src/Runner.Worker/GitHubContext.cs index cdefd45b405..f320705eba0 100644 --- a/src/Runner.Worker/GitHubContext.cs +++ b/src/Runner.Worker/GitHubContext.cs @@ -21,6 +21,7 @@ public sealed class GitHubContext : DictionaryContextData, IEnvironmentContextDa "graphql_url", "head_ref", "job", + "output", "path", "ref_name", "ref_protected", @@ -34,6 +35,7 @@ public sealed class GitHubContext : DictionaryContextData, IEnvironmentContextDa "run_number", "server_url", "sha", + "state", "step_summary", "triggering_actor", "workflow", diff --git a/src/Test/L0/Worker/SaveStateFileCommandL0.cs b/src/Test/L0/Worker/SaveStateFileCommandL0.cs new file mode 100644 index 00000000000..45296c70978 --- /dev/null +++ b/src/Test/L0/Worker/SaveStateFileCommandL0.cs @@ -0,0 +1,438 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Runtime.CompilerServices; +using GitHub.Runner.Common.Util; +using GitHub.Runner.Sdk; +using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Container; +using GitHub.Runner.Worker.Handlers; +using Moq; +using Xunit; +using DTWebApi = GitHub.DistributedTask.WebApi; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class SaveStateFileCommandL0 + { + private Mock _executionContext; + private List> _issues; + private string _rootDirectory; + private SaveStateFileCommand _saveStateFileCommand; + private Dictionary _intraActionState; + private ITraceWriter _trace; + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SaveStateFileCommand_DirectoryNotFound() + { + using (var hostContext = Setup()) + { + var stateFile = Path.Combine(_rootDirectory, "directory-not-found", "env"); + _saveStateFileCommand.ProcessCommand(_executionContext.Object, stateFile, null); + Assert.Equal(0, _issues.Count); + Assert.Equal(0, _intraActionState.Count); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SaveStateFileCommand_NotFound() + { + using (var hostContext = Setup()) + { + var stateFile = Path.Combine(_rootDirectory, "file-not-found"); + _saveStateFileCommand.ProcessCommand(_executionContext.Object, stateFile, null); + Assert.Equal(0, _issues.Count); + Assert.Equal(0, _intraActionState.Count); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SaveStateFileCommand_EmptyFile() + { + using (var hostContext = Setup()) + { + var stateFile = Path.Combine(_rootDirectory, "empty-file"); + var content = new List(); + WriteContent(stateFile, content); + _saveStateFileCommand.ProcessCommand(_executionContext.Object, stateFile, null); + Assert.Equal(0, _issues.Count); + Assert.Equal(0, _intraActionState.Count); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SaveStateFileCommand_Simple() + { + using (var hostContext = Setup()) + { + var stateFile = Path.Combine(_rootDirectory, "simple"); + var content = new List + { + "MY_STATE=MY VALUE", + }; + WriteContent(stateFile, content); + _saveStateFileCommand.ProcessCommand(_executionContext.Object, stateFile, null); + Assert.Equal(0, _issues.Count); + Assert.Equal(1, _intraActionState.Count); + Assert.Equal("MY VALUE", _intraActionState["MY_STATE"]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SaveStateFileCommand_Simple_SkipEmptyLines() + { + using (var hostContext = Setup()) + { + var stateFile = Path.Combine(_rootDirectory, "simple"); + var content = new List + { + string.Empty, + "MY_STATE=my value", + string.Empty, + "MY_STATE_2=my second value", + string.Empty, + }; + WriteContent(stateFile, content); + _saveStateFileCommand.ProcessCommand(_executionContext.Object, stateFile, null); + Assert.Equal(0, _issues.Count); + Assert.Equal(2, _intraActionState.Count); + Assert.Equal("my value", _intraActionState["MY_STATE"]); + Assert.Equal("my second value", _intraActionState["MY_STATE_2"]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SaveStateFileCommand_Simple_EmptyValue() + { + using (var hostContext = Setup()) + { + var stateFile = Path.Combine(_rootDirectory, "simple-empty-value"); + var content = new List + { + "MY_STATE=", + }; + WriteContent(stateFile, content); + _saveStateFileCommand.ProcessCommand(_executionContext.Object, stateFile, null); + Assert.Equal(0, _issues.Count); + Assert.Equal(1, _intraActionState.Count); + Assert.Equal(string.Empty, _intraActionState["MY_STATE"]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SaveStateFileCommand_Simple_MultipleValues() + { + using (var hostContext = Setup()) + { + var stateFile = Path.Combine(_rootDirectory, "simple"); + var content = new List + { + "MY_STATE=my value", + "MY_STATE_2=", + "MY_STATE_3=my third value", + }; + WriteContent(stateFile, content); + _saveStateFileCommand.ProcessCommand(_executionContext.Object, stateFile, null); + Assert.Equal(0, _issues.Count); + Assert.Equal(3, _intraActionState.Count); + Assert.Equal("my value", _intraActionState["MY_STATE"]); + Assert.Equal(string.Empty, _intraActionState["MY_STATE_2"]); + Assert.Equal("my third value", _intraActionState["MY_STATE_3"]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SaveStateFileCommand_Simple_SpecialCharacters() + { + using (var hostContext = Setup()) + { + var stateFile = Path.Combine(_rootDirectory, "simple"); + var content = new List + { + "MY_STATE==abc", + "MY_STATE_2=def=ghi", + "MY_STATE_3=jkl=", + }; + WriteContent(stateFile, content); + _saveStateFileCommand.ProcessCommand(_executionContext.Object, stateFile, null); + Assert.Equal(0, _issues.Count); + Assert.Equal(3, _intraActionState.Count); + Assert.Equal("=abc", _intraActionState["MY_STATE"]); + Assert.Equal("def=ghi", _intraActionState["MY_STATE_2"]); + Assert.Equal("jkl=", _intraActionState["MY_STATE_3"]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SaveStateFileCommand_Heredoc() + { + using (var hostContext = Setup()) + { + var stateFile = Path.Combine(_rootDirectory, "heredoc"); + var content = new List + { + "MY_STATE< + { + "MY_STATE< + { + string.Empty, + "MY_STATE< + { + "MY_STATE<<=EOF", + "hello", + "one", + "=EOF", + "MY_STATE_2<< + { + "MY_STATE<(() => _saveStateFileCommand.ProcessCommand(_executionContext.Object, stateFile, null)); + Assert.Contains("Matching delimiter not found", ex.Message); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SaveStateFileCommand_Heredoc_MissingNewLineMultipleLines() + { + using (var hostContext = Setup()) + { + var stateFile = Path.Combine(_rootDirectory, "heredoc"); + var content = new List + { + "MY_STATE<(() => _saveStateFileCommand.ProcessCommand(_executionContext.Object, stateFile, null)); + Assert.Contains("EOF marker missing new line", ex.Message); + } + } + +#if OS_WINDOWS + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SaveStateFileCommand_Heredoc_PreservesNewline() + { + using (var hostContext = Setup()) + { + var newline = "\n"; + var stateFile = Path.Combine(_rootDirectory, "heredoc"); + var content = new List + { + "MY_STATE< content, + string newline = null) + { + if (string.IsNullOrEmpty(newline)) + { + newline = Environment.NewLine; + } + + var encoding = new UTF8Encoding(true); // Emit BOM + var contentStr = string.Join(newline, content); + File.WriteAllText(path, contentStr, encoding); + } + + private TestHostContext Setup([CallerMemberName] string name = "") + { + _issues = new List>(); + _intraActionState = new Dictionary(); + + var hostContext = new TestHostContext(this, name); + + // Trace + _trace = hostContext.GetTrace(); + + // Directory for test data + var workDirectory = hostContext.GetDirectory(WellKnownDirectory.Work); + ArgUtil.NotNullOrEmpty(workDirectory, nameof(workDirectory)); + Directory.CreateDirectory(workDirectory); + _rootDirectory = Path.Combine(workDirectory, nameof(SaveStateFileCommandL0)); + Directory.CreateDirectory(_rootDirectory); + + // Execution context + _executionContext = new Mock(); + _executionContext.Setup(x => x.Global) + .Returns(new GlobalContext + { + EnvironmentVariables = new Dictionary(VarUtil.EnvironmentVariableKeyComparer), + WriteDebug = true, + }); + _executionContext.Setup(x => x.AddIssue(It.IsAny(), It.IsAny())) + .Callback((DTWebApi.Issue issue, string logMessage) => + { + _issues.Add(new Tuple(issue, logMessage)); + var message = !string.IsNullOrEmpty(logMessage) ? logMessage : issue.Message; + _trace.Info($"Issue '{issue.Type}': {message}"); + }); + _executionContext.Setup(x => x.Write(It.IsAny(), It.IsAny())) + .Callback((string tag, string message) => + { + _trace.Info($"{tag}{message}"); + }); + _executionContext.Setup(x => x.IntraActionState) + .Returns(_intraActionState); + + // SaveStateFileCommand + _saveStateFileCommand = new SaveStateFileCommand(); + _saveStateFileCommand.Initialize(hostContext); + + return hostContext; + } + } +} diff --git a/src/Test/L0/Worker/SetOutputFileCommandL0.cs b/src/Test/L0/Worker/SetOutputFileCommandL0.cs new file mode 100644 index 00000000000..8af9695db46 --- /dev/null +++ b/src/Test/L0/Worker/SetOutputFileCommandL0.cs @@ -0,0 +1,444 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Runtime.CompilerServices; +using GitHub.Runner.Common.Util; +using GitHub.Runner.Sdk; +using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Container; +using GitHub.Runner.Worker.Handlers; +using Moq; +using Xunit; +using DTWebApi = GitHub.DistributedTask.WebApi; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class SetOutputFileCommandL0 + { + private Mock _executionContext; + private List> _issues; + private Dictionary _outputs; + private string _rootDirectory; + private SetOutputFileCommand _setOutputFileCommand; + private ITraceWriter _trace; + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SetOutputFileCommand_DirectoryNotFound() + { + using (var hostContext = Setup()) + { + var stateFile = Path.Combine(_rootDirectory, "directory-not-found", "env"); + _setOutputFileCommand.ProcessCommand(_executionContext.Object, stateFile, null); + Assert.Equal(0, _issues.Count); + Assert.Equal(0, _outputs.Count); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SetOutputFileCommand_NotFound() + { + using (var hostContext = Setup()) + { + var stateFile = Path.Combine(_rootDirectory, "file-not-found"); + _setOutputFileCommand.ProcessCommand(_executionContext.Object, stateFile, null); + Assert.Equal(0, _issues.Count); + Assert.Equal(0, _outputs.Count); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SetOutputFileCommand_EmptyFile() + { + using (var hostContext = Setup()) + { + var stateFile = Path.Combine(_rootDirectory, "empty-file"); + var content = new List(); + WriteContent(stateFile, content); + _setOutputFileCommand.ProcessCommand(_executionContext.Object, stateFile, null); + Assert.Equal(0, _issues.Count); + Assert.Equal(0, _outputs.Count); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SetOutputFileCommand_Simple() + { + using (var hostContext = Setup()) + { + var stateFile = Path.Combine(_rootDirectory, "simple"); + var content = new List + { + "MY_OUTPUT=MY VALUE", + }; + WriteContent(stateFile, content); + _setOutputFileCommand.ProcessCommand(_executionContext.Object, stateFile, null); + Assert.Equal(0, _issues.Count); + Assert.Equal(1, _outputs.Count); + Assert.Equal("MY VALUE", _outputs["MY_OUTPUT"]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SetOutputFileCommand_Simple_SkipEmptyLines() + { + using (var hostContext = Setup()) + { + var stateFile = Path.Combine(_rootDirectory, "simple"); + var content = new List + { + string.Empty, + "MY_OUTPUT=my value", + string.Empty, + "MY_OUTPUT_2=my second value", + string.Empty, + }; + WriteContent(stateFile, content); + _setOutputFileCommand.ProcessCommand(_executionContext.Object, stateFile, null); + Assert.Equal(0, _issues.Count); + Assert.Equal(2, _outputs.Count); + Assert.Equal("my value", _outputs["MY_OUTPUT"]); + Assert.Equal("my second value", _outputs["MY_OUTPUT_2"]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SetOutputFileCommand_Simple_EmptyValue() + { + using (var hostContext = Setup()) + { + var stateFile = Path.Combine(_rootDirectory, "simple-empty-value"); + var content = new List + { + "MY_OUTPUT=", + }; + WriteContent(stateFile, content); + _setOutputFileCommand.ProcessCommand(_executionContext.Object, stateFile, null); + Assert.Equal(0, _issues.Count); + Assert.Equal(1, _outputs.Count); + Assert.Equal(string.Empty, _outputs["MY_OUTPUT"]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SetOutputFileCommand_Simple_MultipleValues() + { + using (var hostContext = Setup()) + { + var stateFile = Path.Combine(_rootDirectory, "simple"); + var content = new List + { + "MY_OUTPUT=my value", + "MY_OUTPUT_2=", + "MY_OUTPUT_3=my third value", + }; + WriteContent(stateFile, content); + _setOutputFileCommand.ProcessCommand(_executionContext.Object, stateFile, null); + Assert.Equal(0, _issues.Count); + Assert.Equal(3, _outputs.Count); + Assert.Equal("my value", _outputs["MY_OUTPUT"]); + Assert.Equal(string.Empty, _outputs["MY_OUTPUT_2"]); + Assert.Equal("my third value", _outputs["MY_OUTPUT_3"]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SetOutputFileCommand_Simple_SpecialCharacters() + { + using (var hostContext = Setup()) + { + var stateFile = Path.Combine(_rootDirectory, "simple"); + var content = new List + { + "MY_OUTPUT==abc", + "MY_OUTPUT_2=def=ghi", + "MY_OUTPUT_3=jkl=", + }; + WriteContent(stateFile, content); + _setOutputFileCommand.ProcessCommand(_executionContext.Object, stateFile, null); + Assert.Equal(0, _issues.Count); + Assert.Equal(3, _outputs.Count); + Assert.Equal("=abc", _outputs["MY_OUTPUT"]); + Assert.Equal("def=ghi", _outputs["MY_OUTPUT_2"]); + Assert.Equal("jkl=", _outputs["MY_OUTPUT_3"]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SetOutputFileCommand_Heredoc() + { + using (var hostContext = Setup()) + { + var stateFile = Path.Combine(_rootDirectory, "heredoc"); + var content = new List + { + "MY_OUTPUT< + { + "MY_OUTPUT< + { + string.Empty, + "MY_OUTPUT< + { + "MY_OUTPUT<<=EOF", + "hello", + "one", + "=EOF", + "MY_OUTPUT_2<< + { + "MY_OUTPUT<(() => _setOutputFileCommand.ProcessCommand(_executionContext.Object, stateFile, null)); + Assert.Contains("Matching delimiter not found", ex.Message); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SetOutputFileCommand_Heredoc_MissingNewLineMultipleLines() + { + using (var hostContext = Setup()) + { + var stateFile = Path.Combine(_rootDirectory, "heredoc"); + var content = new List + { + "MY_OUTPUT<(() => _setOutputFileCommand.ProcessCommand(_executionContext.Object, stateFile, null)); + Assert.Contains("EOF marker missing new line", ex.Message); + } + } + +#if OS_WINDOWS + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SetOutputFileCommand_Heredoc_PreservesNewline() + { + using (var hostContext = Setup()) + { + var newline = "\n"; + var stateFile = Path.Combine(_rootDirectory, "heredoc"); + var content = new List + { + "MY_OUTPUT< content, + string newline = null) + { + if (string.IsNullOrEmpty(newline)) + { + newline = Environment.NewLine; + } + + var encoding = new UTF8Encoding(true); // Emit BOM + var contentStr = string.Join(newline, content); + File.WriteAllText(path, contentStr, encoding); + } + + private TestHostContext Setup([CallerMemberName] string name = "") + { + _issues = new List>(); + _outputs = new Dictionary(); + + var hostContext = new TestHostContext(this, name); + + // Trace + _trace = hostContext.GetTrace(); + + // Directory for test data + var workDirectory = hostContext.GetDirectory(WellKnownDirectory.Work); + ArgUtil.NotNullOrEmpty(workDirectory, nameof(workDirectory)); + Directory.CreateDirectory(workDirectory); + _rootDirectory = Path.Combine(workDirectory, nameof(SetOutputFileCommandL0)); + Directory.CreateDirectory(_rootDirectory); + + // Execution context + _executionContext = new Mock(); + _executionContext.Setup(x => x.Global) + .Returns(new GlobalContext + { + EnvironmentVariables = new Dictionary(VarUtil.EnvironmentVariableKeyComparer), + WriteDebug = true, + }); + _executionContext.Setup(x => x.AddIssue(It.IsAny(), It.IsAny())) + .Callback((DTWebApi.Issue issue, string logMessage) => + { + _issues.Add(new Tuple(issue, logMessage)); + var message = !string.IsNullOrEmpty(logMessage) ? logMessage : issue.Message; + _trace.Info($"Issue '{issue.Type}': {message}"); + }); + _executionContext.Setup(x => x.Write(It.IsAny(), It.IsAny())) + .Callback((string tag, string message) => + { + _trace.Info($"{tag}{message}"); + }); + + var reference = string.Empty; + _executionContext.Setup(x => x.SetOutput(It.IsAny(), It.IsAny(), out reference)) + .Callback((string name, string value, out string reference) => + { + reference = value; + _outputs[name] = value; + }); + + // SetOutputFileCommand + _setOutputFileCommand = new SetOutputFileCommand(); + _setOutputFileCommand.Initialize(hostContext); + + return hostContext; + } + } +}