using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.Scripting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// /// Flow Debugger design 2026-09-05 §5.1/§5.4: a traced makes /// the launched run emit a ScriptRun row and return its ExecutionId plus the /// inline trace events on the ; a traced, stamped /// parents the value-change run to the writer. Untraced /// paths are pinned unchanged. /// public class ScriptActorTraceTests : TestKit, IDisposable { private sealed class CapturingAuditWriter : IAuditWriter { public List Events { get; } = new(); public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { lock (Events) Events.Add(evt); return Task.CompletedTask; } public AuditEvent? WaitForKind(string action, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { lock (Events) { var hit = Events.FirstOrDefault(e => e.Action == action); if (hit is not null) return hit; } Thread.Sleep(25); } return null; } } private readonly SharedScriptLibrary _sharedLibrary; private readonly SiteRuntimeOptions _options; private readonly ScriptExecutionScheduler _scheduler; private readonly CapturingAuditWriter _writer = new(); private readonly IServiceProvider _services; public ScriptActorTraceTests() { _sharedLibrary = new SharedScriptLibrary( new ScriptCompilationService(NullLogger.Instance), NullLogger.Instance); _options = new SiteRuntimeOptions { MaxScriptCallDepth = 10, ScriptExecutionTimeoutSeconds = 30 }; _scheduler = new ScriptExecutionScheduler(_options.ScriptExecutionThreadCount); var sc = new ServiceCollection(); sc.AddSingleton(_writer); _services = sc.BuildServiceProvider(); } void IDisposable.Dispose() { Shutdown(); _scheduler.Dispose(); } private static Script Compile(string code) { var script = CSharpScript.Create(code, ScriptOptions.Default.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly) .WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks"), typeof(ScriptGlobals)); script.Compile(); return script; } private IActorRef CreateActor(string name, string code, string? triggerJson = null) { var config = new ResolvedScript { CanonicalName = name, Code = code, TriggerType = triggerJson is null ? null : "valuechange", TriggerConfiguration = triggerJson }; return ActorOf(Props.Create(() => new ScriptActor( name, "Pump1", CreateTestProbe().Ref, Compile(code), config, _sharedLibrary, _options, NullLogger.Instance, null, null, null, _services, _scheduler))); } [Fact] public void Call_WithTrace_ReplyCarriesExecutionId_AndAScriptRunEvent() { var actor = CreateActor("Add", "(int)Parameters[\"x\"] + 1"); actor.Tell(new ScriptCallRequest("Add", new Dictionary { ["x"] = 41 }, 0, "cid", Trace: true)); var result = ExpectMsg(TimeSpan.FromSeconds(10)); Assert.True(result.Success, result.ErrorMessage); Assert.Equal(42, result.ReturnValue); Assert.NotNull(result.ExecutionId); var evt = Assert.Single(result.TraceEvents!); var row = evt.AsRow(); Assert.Equal("ScriptTrace.ScriptRun", evt.Action); Assert.Equal("Delivered", row.Status.ToString()); Assert.Equal(result.ExecutionId, row.ExecutionId); Assert.Equal("Add", evt.Target); Assert.Equal("Pump1", row.SourceInstanceId); Assert.Contains("\"x\":41", row.RequestSummary); Assert.Equal("42", row.ResponseSummary); Assert.Equal("call", ScriptTraceExtra.TryParse(row.Extra)!.TriggerKind); // The same row went through the audit writer. Assert.NotNull(_writer.WaitForKind("ScriptTrace.ScriptRun", TimeSpan.FromSeconds(5))); } [Fact] public void Call_WithTrace_FailedRun_ReturnsFailedScriptRunEvent() { var actor = CreateActor("Boom", "throw new InvalidOperationException(\"kaboom\");"); actor.Tell(new ScriptCallRequest("Boom", null, 0, "cid", Trace: true)); var result = ExpectMsg(TimeSpan.FromSeconds(10)); Assert.False(result.Success); Assert.NotNull(result.ExecutionId); var row = Assert.Single(result.TraceEvents!).AsRow(); Assert.Equal("Failed", row.Status.ToString()); Assert.Contains("kaboom", row.ErrorMessage); } [Fact] public void Call_WithoutTrace_ReplyHasNoTraceEvents_ButStillCarriesExecutionId() { var actor = CreateActor("Answer", "42"); actor.Tell(new ScriptCallRequest("Answer", null, 0, "cid")); var result = ExpectMsg(TimeSpan.FromSeconds(10)); Assert.True(result.Success); Assert.NotNull(result.ExecutionId); Assert.Null(result.TraceEvents); Thread.Sleep(200); Assert.Empty(_writer.Events); } [Fact] public void ValueChangeTrigger_TracedChange_RunParentsToSourceExecutionId() { var actor = CreateActor("OnStatus", "1", "{\"attribute\":\"Status\"}"); var writer = Guid.NewGuid(); var now = DateTimeOffset.UtcNow; actor.Tell(new AttributeValueChanged("Pump1", "Status", "Status", "Running", "Good", now, SourceExecutionId: writer, Trace: true)); var evt = _writer.WaitForKind("ScriptTrace.ScriptRun", TimeSpan.FromSeconds(10)); Assert.NotNull(evt); var row = evt!.AsRow(); Assert.Equal(writer, row.ParentExecutionId); Assert.NotEqual(writer, row.ExecutionId); var extra = ScriptTraceExtra.TryParse(row.Extra)!; Assert.Equal("valuechange", extra.TriggerKind); Assert.Equal("Status", extra.TriggerAttribute); Assert.Equal("Running", extra.TriggerValue); Assert.Equal("value-change", extra.TriggerSource); } [Fact] public void ValueChangeTrigger_UntracedChange_RunStaysRoot_AndEmitsNothing() { var actor = CreateActor("OnStatus", "1", "{\"attribute\":\"Status\"}"); // Stamped but NOT traced (an ordinary script write) — and a plain DCL change. actor.Tell(new AttributeValueChanged("Pump1", "Status", "Status", "Running", "Good", DateTimeOffset.UtcNow, SourceExecutionId: Guid.NewGuid())); actor.Tell(new AttributeValueChanged("Pump1", "Status", "Status", "Stopped", "Good", DateTimeOffset.UtcNow)); Assert.Null(_writer.WaitForKind("ScriptTrace.ScriptRun", TimeSpan.FromSeconds(1))); Assert.Empty(_writer.Events); } }