using ScadaLink.CentralUI.ScriptAnalysis; namespace ScadaLink.CentralUI.Tests.ScriptAnalysis; /// /// Regression tests for the SandboxConsoleCapture writer that the Test Run /// sandbox installs on Console.Out/Console.Error. CentralUI-030 /// surfaced an intra-script concurrency hazard: a sandboxed script can fan out /// work with Task.WhenAll / Task.Run and every child task inherits /// the capture StringWriter via AsyncLocal; StringWriter is /// not thread-safe, so concurrent writes could corrupt the buffer. These tests /// drive the writer the same way Roslyn-hosted user code does. /// public class SandboxConsoleCaptureTests { /// /// CentralUI-030: a capture scope shared across Task.WhenAll child /// tasks must serialise writes so the resulting transcript contains exactly /// the expected number of lines without character-level interleaving. /// [Fact] public async Task BeginCapture_ConcurrentWritesFromTasks_DoNotCorruptBuffer() { // The static install routes Console.Out through the singleton sandbox // capture writer for the test process — this is idempotent and matches // the way ScriptAnalysisService bootstraps the sandbox in production. var (capture, _) = SandboxConsoleCapture.Install(); var buffer = new StringWriter(); const int taskCount = 32; const int linesPerTask = 50; const int expectedLines = taskCount * linesPerTask; using (capture.BeginCapture(buffer)) { // AsyncLocal flows the capture scope into each Task.Run, mirroring // a sandboxed script doing `await Task.WhenAll(...)` over Tasks // that each `Console.WriteLine`. var tasks = Enumerable.Range(0, taskCount).Select(i => Task.Run(() => { for (var j = 0; j < linesPerTask; j++) { Console.WriteLine($"task-{i}-line-{j}"); } })); await Task.WhenAll(tasks); } var captured = buffer.ToString(); // Without the lock, concurrent StringWriter.WriteLine can drop or // interleave characters and produce malformed lines / a wrong count. // We assert the exact line count and that every emitted token is // present on a line of its own — both fail under the unprotected // implementation. var lines = captured.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); Assert.Equal(expectedLines, lines.Length); for (var i = 0; i < taskCount; i++) { for (var j = 0; j < linesPerTask; j++) { Assert.Contains($"task-{i}-line-{j}", lines); } } } /// /// Sanity check: the most basic capture happy-path still works after the /// CentralUI-030 lock was introduced. /// [Fact] public void BeginCapture_SingleThreadedWrites_AreCaptured() { var (capture, _) = SandboxConsoleCapture.Install(); var buffer = new StringWriter(); using (capture.BeginCapture(buffer)) { Console.WriteLine("hello"); Console.Write("world"); } Assert.Contains("hello", buffer.ToString()); Assert.Contains("world", buffer.ToString()); } }