/* ======================================================================== * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; namespace Opc.Ua.Redundancy.Samples.Tests { /// /// Launches one of the redundant sample applications as an external process and /// captures its console output so tests can assert on the high-availability log /// lines the sample emits (for example FAILOVER:, DATA LOSS: and /// HA OK:). /// internal sealed class SampleAppProcess : IAsyncDisposable { /// /// Initializes a new instance of the class and /// starts the sample application. /// /// A short, human-readable name used to prefix captured output. /// The sample application path under samples/. /// The sample application assembly (dll) name without extension. /// The command-line arguments passed to the sample application. /// Additional environment variables set for the process. /// Receives captured output, or uses the NUnit progress stream. public SampleAppProcess( string name, string applicationDirectory, string assemblyName, IReadOnlyList arguments, IReadOnlyDictionary? environment = null, Action? writeOutput = null) { Name = name; m_writeOutput = writeOutput ?? TestContext.Progress.WriteLine; string dll = LocateApplicationAssembly(applicationDirectory, assemblyName); var startInfo = new ProcessStartInfo { FileName = DotNetHostPath, WorkingDirectory = Path.GetDirectoryName(dll)!, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; startInfo.ArgumentList.Add(dll); foreach (string argument in arguments) { startInfo.ArgumentList.Add(argument); } if (environment != null) { foreach (KeyValuePair variable in environment) { startInfo.Environment[variable.Key] = variable.Value; } } m_process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; m_process.OutputDataReceived += OnOutput; m_process.ErrorDataReceived += OnOutput; if (!m_process.Start()) { throw new InvalidOperationException($"Failed to start sample process '{name}'."); } m_process.BeginOutputReadLine(); m_process.BeginErrorReadLine(); } /// /// Gets the short, human-readable name of this sample process. /// public string Name { get; } /// /// Gets a value indicating whether the underlying process has exited. /// public bool HasExited => m_process.HasExited; /// /// Waits until a captured output line contains the given substring, or the timeout elapses. /// /// The substring to search for (ordinal, case-sensitive). /// The maximum time to wait. /// A token used to cancel the wait. /// The first matching line. /// Thrown when no matching line is seen before the timeout. public async Task WaitForLineAsync( string substring, TimeSpan timeout, CancellationToken cancellationToken = default) { return await WaitForLineOrDefaultAsync(substring, timeout, cancellationToken) .ConfigureAwait(false) ?? throw new TimeoutException( $"Sample process '{Name}' did not emit a line containing '{substring}' within {timeout}. " + $"Process {(HasExited ? $"has exited (code {m_process.ExitCode})" : "is still running")}. " + $"Last output:{Environment.NewLine}{GetOutputTail(20)}"); } /// /// Waits until a captured output line contains the given substring, returning null on timeout. /// /// The substring to search for (ordinal, case-sensitive). /// The maximum time to wait. /// A token used to cancel the wait. /// The first matching line, or null if the timeout elapsed. public async Task WaitForLineOrDefaultAsync( string substring, TimeSpan timeout, CancellationToken cancellationToken = default) { DateTime deadline = DateTime.UtcNow + timeout; int index = 0; while (DateTime.UtcNow < deadline) { lock (m_lock) { for (; index < m_lines.Count; index++) { if (m_lines[index].Contains(substring, StringComparison.Ordinal)) { return m_lines[index]; } } } if (HasExited) { TimeSpan remaining = deadline - DateTime.UtcNow; if (remaining <= TimeSpan.Zero || !await WaitForExitAndOutputAsync(remaining, cancellationToken).ConfigureAwait(false)) { return null; } lock (m_lock) { for (; index < m_lines.Count; index++) { if (m_lines[index].Contains(substring, StringComparison.Ordinal)) { return m_lines[index]; } } } return null; } cancellationToken.ThrowIfCancellationRequested(); await Task.Delay(100, cancellationToken).ConfigureAwait(false); } return null; } /// /// Returns whether any captured output line contains the given substring. /// /// The substring to search for (ordinal, case-sensitive). /// true when a matching line has been captured. public bool ContainsLine(string substring) { lock (m_lock) { foreach (string line in m_lines) { if (line.Contains(substring, StringComparison.Ordinal)) { return true; } } } return false; } /// /// Returns the last captured output line containing the given substring, or null when none matches. /// /// The substring to search for (ordinal, case-sensitive). /// The last matching line, or null. public string? LastLineContaining(string substring) { lock (m_lock) { for (int index = m_lines.Count - 1; index >= 0; index--) { if (m_lines[index].Contains(substring, StringComparison.Ordinal)) { return m_lines[index]; } } } return null; } /// /// Returns the index of the last captured output line containing the given substring, or -1. /// /// The substring to search for (ordinal, case-sensitive). /// The zero-based index of the last matching line, or -1. public int LastLineIndexContaining(string substring) { lock (m_lock) { for (int index = m_lines.Count - 1; index >= 0; index--) { if (m_lines[index].Contains(substring, StringComparison.Ordinal)) { return index; } } } return -1; } /// /// Returns the number of captured output lines that contain the given substring. /// /// The substring to search for (ordinal, case-sensitive). /// The number of matching lines. public int CountLinesContaining(string substring) { int count = 0; lock (m_lock) { foreach (string line in m_lines) { if (line.Contains(substring, StringComparison.Ordinal)) { count++; } } } return count; } /// /// Waits until at least captured lines contain the given substring. /// /// The substring to search for (ordinal, case-sensitive). /// The minimum number of matching lines to wait for. /// The maximum time to wait. /// A token used to cancel the wait. /// true when the count was reached before the timeout. public async Task WaitForCountAsync( string substring, int minimumCount, TimeSpan timeout, CancellationToken cancellationToken = default) { DateTime deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { if (CountLinesContaining(substring) >= minimumCount) { return true; } cancellationToken.ThrowIfCancellationRequested(); await Task.Delay(100, cancellationToken).ConfigureAwait(false); } return false; } /// /// Waits for process exit and redirected output completion, or the timeout to elapse. /// /// The maximum time to wait. /// true when the process exited before the timeout. public Task WaitForExitAsync(TimeSpan timeout) { return WaitForExitAndOutputAsync(timeout, CancellationToken.None); } /// /// Terminates the process and its child process tree if it is still running. /// public void Kill() { try { if (!m_process.HasExited) { m_process.Kill(entireProcessTree: true); } } catch (InvalidOperationException) { // The process already exited between the check and the kill. } } /// public async ValueTask DisposeAsync() { Kill(); await WaitForExitAsync(TimeSpan.FromSeconds(15)).ConfigureAwait(false); m_process.OutputDataReceived -= OnOutput; m_process.ErrorDataReceived -= OnOutput; m_process.Dispose(); } /// /// Returns the requested trailing captured lines without accessing the possibly disposed process. /// internal string GetOutputTail(int maximumLines) { lock (m_lock) { int start = Math.Max(0, m_lines.Count - maximumLines); return string.Join(Environment.NewLine, m_lines.GetRange(start, m_lines.Count - start)); } } private async Task WaitForExitAndOutputAsync(TimeSpan timeout, CancellationToken cancellationToken) { using var deadline = new CancellationTokenSource(timeout); using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, deadline.Token); try { await m_process.WaitForExitAsync(linked.Token).ConfigureAwait(false); return true; } catch (OperationCanceledException) when (deadline.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { return false; } } private void OnOutput(object? sender, DataReceivedEventArgs e) { if (e.Data == null) { return; } lock (m_lock) { m_lines.Add(e.Data); } m_writeOutput($"[{Name}] {e.Data}"); } private static string LocateApplicationAssembly(string applicationDirectory, string assemblyName) { string repoRoot = FindRepositoryRoot(); (string configuration, string targetFramework) = CurrentOutputLayout(); var probePaths = new List(); foreach (string config in new[] { configuration, "Release", "Debug" }) { probePaths.Add(Path.Combine( repoRoot, "samples", applicationDirectory, "bin", config, targetFramework, assemblyName + ".dll")); } foreach (string path in probePaths) { if (File.Exists(path)) { return path; } } throw new FileNotFoundException( $"Could not locate the '{assemblyName}' sample assembly. Probed: {string.Join("; ", probePaths)}. " + "Ensure the sample applications are built (they are referenced by this test project)."); } private static (string Configuration, string TargetFramework) CurrentOutputLayout() { // The test assembly runs from .../bin///; the samples // follow CustomTestTarget like this project, so reuse both segments when // locating the sibling sample application output. string baseDirectory = AppContext.BaseDirectory.Replace('\\', '/').TrimEnd('/'); string[] segments = baseDirectory.Split('/'); for (int index = segments.Length - 2; index > 0; index--) { if (string.Equals(segments[index - 1], "bin", StringComparison.OrdinalIgnoreCase)) { return (segments[index], segments[index + 1]); } } return ("Release", "net10.0"); } private static string FindRepositoryRoot() { var directory = new DirectoryInfo(AppContext.BaseDirectory); while (directory != null) { if (File.Exists(Path.Combine(directory.FullName, "UA.slnx"))) { return directory.FullName; } directory = directory.Parent; } throw new DirectoryNotFoundException( "Could not locate the repository root (no UA.slnx found above the test output directory)."); } private static string DotNetHostPath { get { string? root = Environment.GetEnvironmentVariable("DOTNET_ROOT"); string executable = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; if (!string.IsNullOrEmpty(root)) { string candidate = Path.Combine(root, executable); if (File.Exists(candidate)) { return candidate; } } return "dotnet"; } } private readonly Process m_process; private readonly Action m_writeOutput; private readonly List m_lines = []; #if NET9_0_OR_GREATER private readonly Lock m_lock = new(); #else private readonly object m_lock = new(); #endif } }