using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.Scripting; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// /// Regression coverage for SiteRuntime-016 — the short-lived execution actors /// (, ) were /// previously untested. Covers success, exception, timeout, Ask-reply, and the /// PoisonPill self-stop after completion. /// public class ExecutionActorTests : TestKit, IDisposable { private readonly SharedScriptLibrary _sharedLibrary; private readonly ScriptCompilationService _compilationService; public ExecutionActorTests() { _compilationService = new ScriptCompilationService( NullLogger.Instance); _sharedLibrary = new SharedScriptLibrary( _compilationService, NullLogger.Instance); } void IDisposable.Dispose() => Shutdown(); private static Script CompileScript(string code) { var scriptOptions = ScriptOptions.Default .WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly) .WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks"); var script = CSharpScript.Create(code, scriptOptions, typeof(ScriptGlobals)); script.Compile(); return script; } private static SiteRuntimeOptions Options(int timeoutSeconds = 30) => new() { MaxScriptCallDepth = 10, ScriptExecutionTimeoutSeconds = timeoutSeconds }; // ── ScriptExecutionActor ── [Fact] public void ScriptExecutionActor_Success_RepliesWithResultAndStops() { var compiled = CompileScript("return 7 * 6;"); var replyTo = CreateTestProbe(); var instanceActor = CreateTestProbe(); var exec = ActorOf(Props.Create(() => new ScriptExecutionActor( "Answer", "Inst1", compiled, null, 0, instanceActor.Ref, _sharedLibrary, Options(), replyTo.Ref, "corr-1", NullLogger.Instance, ScriptScope.Root, null, null))); Watch(exec); var result = replyTo.ExpectMsg(TimeSpan.FromSeconds(10)); Assert.True(result.Success); Assert.Equal("corr-1", result.CorrelationId); Assert.Equal(42, result.ReturnValue); // The actor must PoisonPill itself once execution completes. ExpectTerminated(exec, TimeSpan.FromSeconds(5)); } // ── M1.8: site event log `script` started/completed ──────────────────── [Fact] public void ScriptExecutionActor_Success_EmitsScriptStartedAndCompletedInfoEvents() { var compiled = CompileScript("return 7 * 6;"); var replyTo = CreateTestProbe(); var instanceActor = CreateTestProbe(); var siteLog = new FakeSiteEventLogger(); var exec = ActorOf(Props.Create(() => new ScriptExecutionActor( "Answer", "Inst1", compiled, null, 0, instanceActor.Ref, _sharedLibrary, Options(), replyTo.Ref, "corr-evt-1", NullLogger.Instance, ScriptScope.Root, null, new SingleServiceProvider(siteLog)))); Watch(exec); replyTo.ExpectMsg(TimeSpan.FromSeconds(10)); ExpectTerminated(exec, TimeSpan.FromSeconds(5)); AwaitAssert(() => { var rows = siteLog.OfType("script"); // started + completed, both Info, in order. Assert.Equal(2, rows.Count); Assert.All(rows, r => { Assert.Equal("Info", r.Severity); Assert.Equal("Inst1", r.InstanceId); Assert.Equal("ScriptActor:Answer", r.Source); }); Assert.Contains("started", rows[0].Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("completed", rows[1].Message, StringComparison.OrdinalIgnoreCase); }, TimeSpan.FromSeconds(2)); } [Fact] public void ScriptExecutionActor_Failure_EmitsStartedInfoThenErrorEvent() { var compiled = CompileScript("throw new InvalidOperationException(\"boom\");"); var replyTo = CreateTestProbe(); var instanceActor = CreateTestProbe(); var siteLog = new FakeSiteEventLogger(); var exec = ActorOf(Props.Create(() => new ScriptExecutionActor( "Bad", "Inst1", compiled, null, 0, instanceActor.Ref, _sharedLibrary, Options(), replyTo.Ref, "corr-evt-2", NullLogger.Instance, ScriptScope.Root, null, new SingleServiceProvider(siteLog)))); Watch(exec); replyTo.ExpectMsg(TimeSpan.FromSeconds(10)); ExpectTerminated(exec, TimeSpan.FromSeconds(5)); AwaitAssert(() => { var rows = siteLog.OfType("script"); // started (Info) + failed (Error) — no completed. Assert.Equal(2, rows.Count); Assert.Equal("Info", rows[0].Severity); Assert.Contains("started", rows[0].Message, StringComparison.OrdinalIgnoreCase); Assert.Equal("Error", rows[1].Severity); }, TimeSpan.FromSeconds(2)); } [Fact] public void ScriptExecutionActor_ScriptThrows_RepliesFailureAndStops() { var compiled = CompileScript("throw new InvalidOperationException(\"boom\");"); var replyTo = CreateTestProbe(); var instanceActor = CreateTestProbe(); var exec = ActorOf(Props.Create(() => new ScriptExecutionActor( "Bad", "Inst1", compiled, null, 0, instanceActor.Ref, _sharedLibrary, Options(), replyTo.Ref, "corr-2", NullLogger.Instance, ScriptScope.Root, null, null))); Watch(exec); var result = replyTo.ExpectMsg(TimeSpan.FromSeconds(10)); Assert.False(result.Success); Assert.Equal("corr-2", result.CorrelationId); Assert.Contains("boom", result.ErrorMessage); ExpectTerminated(exec, TimeSpan.FromSeconds(5)); } [Fact] public void ScriptExecutionActor_Timeout_RepliesFailureAndStops() { // A long busy loop that observes the cancellation token so the // 1-second timeout fires cooperatively. var compiled = CompileScript( "while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }"); var replyTo = CreateTestProbe(); var instanceActor = CreateTestProbe(); var exec = ActorOf(Props.Create(() => new ScriptExecutionActor( "Slow", "Inst1", compiled, null, 0, instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1), replyTo.Ref, "corr-3", NullLogger.Instance, ScriptScope.Root, null, null))); Watch(exec); var result = replyTo.ExpectMsg(TimeSpan.FromSeconds(10)); Assert.False(result.Success); Assert.Contains("timed out", result.ErrorMessage); ExpectTerminated(exec, TimeSpan.FromSeconds(5)); } [Fact] public void ScriptExecutionActor_PerScriptTimeout_OverridesLongerGlobal() { // M2.5 (#9): a short per-script timeout (1s) must win over a long global // (300s), so the busy loop is cancelled at the per-script value. var compiled = CompileScript( "while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }"); var replyTo = CreateTestProbe(); var instanceActor = CreateTestProbe(); var exec = ActorOf(Props.Create(() => new ScriptExecutionActor( "Slow", "Inst1", compiled, null, 0, instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 300), replyTo.Ref, "corr-perscript", NullLogger.Instance, ScriptScope.Root, null, null, null, /* executionTimeoutSeconds */ 1))); Watch(exec); var result = replyTo.ExpectMsg(TimeSpan.FromSeconds(10)); Assert.False(result.Success); Assert.Contains("timed out", result.ErrorMessage); ExpectTerminated(exec, TimeSpan.FromSeconds(5)); } [Fact] public void ScriptExecutionActor_NullPerScriptTimeout_FallsBackToGlobal() { // M2.5 (#9): a null per-script timeout falls back to the global (1s here), // so the busy loop is still cancelled at the global value. var compiled = CompileScript( "while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }"); var replyTo = CreateTestProbe(); var instanceActor = CreateTestProbe(); var exec = ActorOf(Props.Create(() => new ScriptExecutionActor( "Slow", "Inst1", compiled, null, 0, instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1), replyTo.Ref, "corr-fallback", NullLogger.Instance, ScriptScope.Root, null, null, null, /* executionTimeoutSeconds */ null))); Watch(exec); var result = replyTo.ExpectMsg(TimeSpan.FromSeconds(10)); Assert.False(result.Success); Assert.Contains("timed out", result.ErrorMessage); ExpectTerminated(exec, TimeSpan.FromSeconds(5)); } [Fact] public void ScriptExecutionActor_NonPositivePerScriptTimeout_FallsBackToGlobal() { // M2.5 (#9): a non-positive per-script value (<= 0) is treated as "use // global", so the busy loop is cancelled at the global (1s) value. var compiled = CompileScript( "while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }"); var replyTo = CreateTestProbe(); var instanceActor = CreateTestProbe(); var exec = ActorOf(Props.Create(() => new ScriptExecutionActor( "Slow", "Inst1", compiled, null, 0, instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1), replyTo.Ref, "corr-clamp", NullLogger.Instance, ScriptScope.Root, null, null, null, /* executionTimeoutSeconds */ 0))); Watch(exec); var result = replyTo.ExpectMsg(TimeSpan.FromSeconds(10)); Assert.False(result.Success); Assert.Contains("timed out", result.ErrorMessage); ExpectTerminated(exec, TimeSpan.FromSeconds(5)); } [Fact] public void ScriptExecutionActor_NoReplyTo_StillStopsAfterCompletion() { var compiled = CompileScript("return 1;"); var instanceActor = CreateTestProbe(); // ActorRefs.Nobody as replyTo — fire-and-forget execution. var exec = ActorOf(Props.Create(() => new ScriptExecutionActor( "FireForget", "Inst1", compiled, null, 0, instanceActor.Ref, _sharedLibrary, Options(), ActorRefs.Nobody, "corr-4", NullLogger.Instance, ScriptScope.Root, null, null))); Watch(exec); ExpectTerminated(exec, TimeSpan.FromSeconds(5)); } // ── AlarmExecutionActor ── [Fact] public void AlarmExecutionActor_Success_StopsAfterCompletion() { var compiled = CompileScript("return 0;"); var instanceActor = CreateTestProbe(); var exec = ActorOf(Props.Create(() => new AlarmExecutionActor( "HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature", compiled, instanceActor.Ref, _sharedLibrary, Options(), NullLogger.Instance))); Watch(exec); ExpectTerminated(exec, TimeSpan.FromSeconds(5)); } [Fact] public void AlarmExecutionActor_ScriptThrows_StillStops() { var compiled = CompileScript("throw new System.Exception(\"alarm-boom\");"); var instanceActor = CreateTestProbe(); var exec = ActorOf(Props.Create(() => new AlarmExecutionActor( "HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature", compiled, instanceActor.Ref, _sharedLibrary, Options(), NullLogger.Instance))); Watch(exec); // Even on a throwing on-trigger body, the actor must self-stop. ExpectTerminated(exec, TimeSpan.FromSeconds(5)); } [Fact] public void AlarmExecutionActor_PerScriptTimeout_OverridesLongerGlobal() { // M2.5 (#9): the alarm on-trigger script's per-script timeout (1s) wins // over a long global (300s). The busy loop is cancelled and the actor // self-stops (the timeout is logged, alarm continues). var compiled = CompileScript( "while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }"); var instanceActor = CreateTestProbe(); var exec = ActorOf(Props.Create(() => new AlarmExecutionActor( "HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature", compiled, instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 300), NullLogger.Instance, /* executionTimeoutSeconds */ 1))); Watch(exec); // If the per-script timeout were ignored it would block ~300s and this // ExpectTerminated would fail; with the override it stops within ~1s. ExpectTerminated(exec, TimeSpan.FromSeconds(10)); } [Fact] public void AlarmExecutionActor_NullPerScriptTimeout_FallsBackToGlobal() { // M2.5 (#9): a null per-script timeout falls back to the global (1s here), // so the busy loop is cancelled at the global value and the actor self-stops. var compiled = CompileScript( "while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }"); var instanceActor = CreateTestProbe(); var exec = ActorOf(Props.Create(() => new AlarmExecutionActor( "HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature", compiled, instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1), NullLogger.Instance, /* executionTimeoutSeconds */ null))); Watch(exec); // Global timeout (1s) must fire even when per-script is null. ExpectTerminated(exec, TimeSpan.FromSeconds(10)); } [Fact] public void AlarmExecutionActor_NonPositivePerScriptTimeout_FallsBackToGlobal() { // M2.5 (#9): a non-positive per-script value (<= 0) is treated as "use // global", so the busy loop is cancelled at the global (1s) value. var compiled = CompileScript( "while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }"); var instanceActor = CreateTestProbe(); var exec = ActorOf(Props.Create(() => new AlarmExecutionActor( "HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature", compiled, instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1), NullLogger.Instance, /* executionTimeoutSeconds */ 0))); Watch(exec); // Non-positive per-script timeout must be ignored; global (1s) must fire. ExpectTerminated(exec, TimeSpan.FromSeconds(10)); } }