| File: WorkflowScripts\NodeCommand.cs | Web Access |
| Project: src\tests\Infrastructure.Tests\Infrastructure.Tests.csproj (Infrastructure.Tests) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; using System.Runtime.InteropServices; using Xunit; namespace Infrastructure.Tests; /// <summary> /// Executes Node.js scripts using <c>node</c>. /// </summary> public sealed class NodeCommand : IDisposable { private readonly ITestOutputHelper _testOutput; private readonly string _label; private readonly string _msgPrefix; private TimeSpan? _timeout; public NodeCommand(ITestOutputHelper testOutput, string label = "") { _testOutput = testOutput; _label = label; _msgPrefix = string.IsNullOrEmpty(_label) ? string.Empty : $"[{_label}] "; } public Process? CurrentProcess { get; private set; } public Dictionary<string, string> Environment { get; } = []; public string? WorkingDirectory { get; private set; } public NodeCommand WithEnvironmentVariable(string key, string value) { Environment[key] = value; return this; } public NodeCommand WithTimeout(TimeSpan timeSpan) { _timeout = timeSpan; return this; } public NodeCommand WithWorkingDirectory(string dir) { WorkingDirectory = dir; return this; } public async Task<CommandResult> ExecuteScriptAsync(string scriptPath, params string[] args) { CancellationTokenSource cts = new(); if (_timeout is not null) { cts.CancelAfter((int)_timeout.Value.TotalMilliseconds); } try { return await ExecuteScriptAsyncInternal(scriptPath, args, cts.Token).ConfigureAwait(false); } catch (TaskCanceledException tce) when (cts.IsCancellationRequested) { throw new TaskCanceledException( $"Command execution timed out after {_timeout!.Value.TotalSeconds} secs: node {scriptPath}", tce); } } public void Dispose() { CurrentProcess?.CloseAndKillProcessIfRunning(); } private async Task<CommandResult> ExecuteScriptAsyncInternal(string scriptPath, string[] args, CancellationToken token) { Stopwatch runTimeStopwatch = new(); _testOutput.WriteLine($"{_msgPrefix}Executing - node {BuildDisplayArguments(scriptPath, args)} {WorkingDirectoryInfo()}"); object outputLock = new(); List<string> outputLines = []; CurrentProcess = CreateProcess(scriptPath, args); CurrentProcess.ErrorDataReceived += (s, e) => { if (e.Data is null) { return; } lock (outputLock) { outputLines.Add(e.Data); } _testOutput.WriteLine($"{_msgPrefix}{e.Data}"); }; CurrentProcess.OutputDataReceived += (s, e) => { if (e.Data is null) { return; } lock (outputLock) { outputLines.Add(e.Data); } _testOutput.WriteLine($"{_msgPrefix}{e.Data}"); }; try { runTimeStopwatch.Start(); TaskCompletionSource exitedTcs = new(); CurrentProcess.EnableRaisingEvents = true; CurrentProcess.Exited += (_, _) => { exitedTcs.SetResult(); runTimeStopwatch.Stop(); }; CurrentProcess.Start(); CurrentProcess.BeginOutputReadLine(); CurrentProcess.BeginErrorReadLine(); await exitedTcs.Task.WaitAsync(token).ConfigureAwait(false); _testOutput.WriteLine($"{_msgPrefix}Got the Exited event, waiting on WaitForExitAsync"); var waitForExitTask = CurrentProcess.WaitForExitAsync(token); var completedTask = await Task.WhenAny(waitForExitTask, Task.Delay(TimeSpan.FromSeconds(5), token)).ConfigureAwait(false); if (completedTask != waitForExitTask) { _testOutput.WriteLine($"{_msgPrefix}Timed out waiting for it. Ignoring."); } _testOutput.WriteLine($"{_msgPrefix}Process ran for {runTimeStopwatch.Elapsed.TotalSeconds:F2} secs"); return new CommandResult( CurrentProcess.StartInfo, CurrentProcess.ExitCode, GetFullOutput()); } catch (Exception ex) { _testOutput.WriteLine($"Exception: {ex}"); _testOutput.WriteLine($"output: {GetFullOutput()}"); throw; } finally { if (!CurrentProcess.TryGetHasExited()) { _testOutput.WriteLine($"{_msgPrefix}Process has been running for {runTimeStopwatch.Elapsed.TotalSeconds:F2} secs"); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { CurrentProcess.CloseMainWindow(); } _testOutput.WriteLine("Killing"); CurrentProcess.Kill(entireProcessTree: true); } CurrentProcess.Dispose(); } string GetFullOutput() { lock (outputLock) { return string.Join(System.Environment.NewLine, outputLines); } } } private Process CreateProcess(string scriptPath, string[] args) { ProcessStartInfo psi = new() { FileName = "node", RedirectStandardError = true, RedirectStandardOutput = true, RedirectStandardInput = true, UseShellExecute = false }; psi.ArgumentList.Add(scriptPath); foreach (string arg in args) { psi.ArgumentList.Add(arg); } AddEnvironmentVariablesTo(psi); AddWorkingDirectoryTo(psi); return new Process { StartInfo = psi, EnableRaisingEvents = true }; } private void AddEnvironmentVariablesTo(ProcessStartInfo psi) { foreach ((string key, string value) in Environment) { _testOutput.WriteLine($"{_msgPrefix}\t[{key}] = {value}"); psi.Environment[key] = value; } } private void AddWorkingDirectoryTo(ProcessStartInfo psi) { if (string.IsNullOrWhiteSpace(WorkingDirectory)) { return; } if (!Directory.Exists(WorkingDirectory)) { throw new DirectoryNotFoundException($"Working directory '{WorkingDirectory}' does not exist."); } psi.WorkingDirectory = WorkingDirectory; } private static string BuildDisplayArguments(string scriptPath, string[] args) => string.Join(" ", [QuoteForDisplay(scriptPath), .. args.Select(QuoteForDisplay)]); private static string QuoteForDisplay(string value) => value.Contains(' ') ? $"\"{value}\"" : value; private string WorkingDirectoryInfo() => WorkingDirectory is null ? string.Empty : $" in pwd {WorkingDirectory}"; }