3032faac0d
The UI script editor has no ExecutionTimeoutSeconds control (authoring deferred), so a body edit silently cleared a timeout set via Transport import. Round-trip the loaded value so UI edits preserve it. Add the missing AlarmExecutionActor null/<=0 fallback tests for symmetry with ScriptExecutionActor.
375 lines
15 KiB
C#
375 lines
15 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Regression coverage for SiteRuntime-016 — the short-lived execution actors
|
|
/// (<see cref="ScriptExecutionActor"/>, <see cref="AlarmExecutionActor"/>) were
|
|
/// previously untested. Covers success, exception, timeout, Ask-reply, and the
|
|
/// PoisonPill self-stop after completion.
|
|
/// </summary>
|
|
public class ExecutionActorTests : TestKit, IDisposable
|
|
{
|
|
private readonly SharedScriptLibrary _sharedLibrary;
|
|
private readonly ScriptCompilationService _compilationService;
|
|
|
|
public ExecutionActorTests()
|
|
{
|
|
_compilationService = new ScriptCompilationService(
|
|
NullLogger<ScriptCompilationService>.Instance);
|
|
_sharedLibrary = new SharedScriptLibrary(
|
|
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
|
}
|
|
|
|
void IDisposable.Dispose() => Shutdown();
|
|
|
|
private static Script<object?> 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<object?>(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<ScriptCallResult>(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<ScriptCallResult>(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<ScriptCallResult>(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<ScriptCallResult>(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<ScriptCallResult>(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<ScriptCallResult>(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<ScriptCallResult>(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<ScriptCallResult>(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));
|
|
}
|
|
}
|