diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/InboundScriptHost.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/InboundScriptHost.cs index 98212fbb..984425ae 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/InboundScriptHost.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/InboundScriptHost.cs @@ -28,6 +28,14 @@ public class InboundScriptHost /// public DatabaseAccessor Database { get; } = new(); + /// + /// Leveled per-run logging. Editor mirror of + /// ZB.MOM.WW.ScadaBridge.InboundAPI.InboundScriptLogger (step logs design 2026-09-09 D4); + /// shares with the Test Run sandbox because only the signatures + /// matter here — analysis never invokes it. + /// + public SandboxLog Log { get; } = new(); + /// /// The cancellation token for the operation. /// diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxInboundScriptHost.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxInboundScriptHost.cs index 238d54eb..dc7de29a 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxInboundScriptHost.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxInboundScriptHost.cs @@ -26,6 +26,13 @@ public class SandboxInboundScriptHost /// Gets the database accessor; every call throws in a test run. public DatabaseAccessor Database { get; } = new(); + /// + /// Sandbox mirror of the inbound runtime's Log global (step logs design 2026-09-09 D4). + /// Present so a method script that logs BINDS in the Test Run editor; lines go to the Test Run + /// console pane and are never audited. + /// + public SandboxLog Log { get; } = new(); + /// /// Mirror of ZB.MOM.WW.ScadaBridge.InboundAPI.InboundDatabaseHelper — signatures match /// the runtime helper so the same user code compiles, but every call throws because a diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs index 662c3ab6..cce4cda0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs @@ -87,6 +87,58 @@ public class SandboxScriptHost /// would show a spurious CS1061 on a script that deploys and runs perfectly. /// public SandboxAlarmsAccessor Alarms { get; } = new(); + + /// + /// Sandbox mirror of the site runtime's Log global (step logs design 2026-09-09 D4). + /// Present so a script that logs BINDS in the Test Run editor; lines go to the Test Run + /// console pane and are never audited. Delegates to + /// exactly as the runtime's ScriptGlobals.Log => Instance.Log does, so + /// Log and Instance.Log are the same object here too. + /// + public SandboxLog Log => Instance.Log; +} + +/// +/// Sandbox mirror of ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.ScriptLogger (step logs +/// design 2026-09-09 D4). Signature-faithful so the same script body compiles here and at a site, +/// but a Test Run has no execution to trace and no audit trail: every line is written to +/// , which the sandbox captures into +/// SandboxRunResult.ConsoleOutput (see ) and shows in +/// the editor's console pane. Nothing here reaches the node log or the audit log. +/// +public class SandboxLog +{ + /// Writes a debug-level line to the Test Run console pane. + /// The message text. + /// An optional exception, rendered on a following line. + public void Debug(string message, Exception? exception = null) => Write("debug", message, exception); + + /// Writes an information-level line to the Test Run console pane. + /// The message text. + /// An optional exception, rendered on a following line. + public void Info(string message, Exception? exception = null) => Write("info", message, exception); + + /// Writes a warning-level line to the Test Run console pane. + /// The message text. + /// An optional exception, rendered on a following line. + public void Warn(string message, Exception? exception = null) => Write("warn", message, exception); + + /// + /// Writes an error-level line to the Test Run console pane. Like the runtime logger, this + /// does NOT fail the run — it is a message, not a throw. + /// + /// The message text. + /// An optional exception, rendered on a following line. + public void Error(string message, Exception? exception = null) => Write("error", message, exception); + + private static void Write(string level, string message, Exception? exception) + { + Console.Out.WriteLine($"[{level}] {message}"); + if (exception != null) + { + Console.Out.WriteLine(exception.ToString()); + } + } } /// @@ -193,6 +245,12 @@ public class SandboxInstanceContext /// public SandboxScriptCallHelper Scripts { get; } + /// + /// Sandbox mirror of ScriptRuntimeContext.Log (step logs design 2026-09-09 D4), so + /// Instance.Log.Info("…") binds and prints in the Test Run console pane. + /// + public SandboxLog Log { get; } = new(); + /// /// Initializes a new instance of the SandboxInstanceContext. /// diff --git a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs index 40e4f6cc..8055f80f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs @@ -67,6 +67,9 @@ public sealed class ScriptCompileSurface /// Mirrors ScriptGlobals.Alarms. public CompileAlarmsAccessor Alarms => throw new NotSupportedException(CompileOnly); + /// Mirrors ScriptGlobals.Log. + public CompileLog Log => throw new NotSupportedException(CompileOnly); + /// Compile-only mirror of ScriptRuntimeContext (the Instance global). public sealed class CompileInstance { @@ -104,6 +107,38 @@ public sealed class ScriptCompileSurface /// Mirrors ScriptRuntimeContext.Alarms. public CompileAlarmsAccessor Alarms => throw new NotSupportedException(CompileOnly); + + /// Mirrors ScriptRuntimeContext.Log. + public CompileLog Log => throw new NotSupportedException(CompileOnly); + } + + /// + /// Compile-only mirror of ScriptLogger (step logs design 2026-09-09 D4) — the + /// Log global a script writes leveled lines through. The optional + /// exception parameter is mirrored so Log.Warn("…", ex) binds at design + /// time exactly as it does at the site. + /// + public sealed class CompileLog + { + /// Mirrors ScriptLogger.Debug. + /// The message text. + /// An optional exception to attach to the line. + public void Debug(string message, Exception? exception = null) => throw new NotSupportedException(CompileOnly); + + /// Mirrors ScriptLogger.Info. + /// The message text. + /// An optional exception to attach to the line. + public void Info(string message, Exception? exception = null) => throw new NotSupportedException(CompileOnly); + + /// Mirrors ScriptLogger.Warn. + /// The message text. + /// An optional exception to attach to the line. + public void Warn(string message, Exception? exception = null) => throw new NotSupportedException(CompileOnly); + + /// Mirrors ScriptLogger.Error. + /// The message text. + /// An optional exception to attach to the line. + public void Error(string message, Exception? exception = null) => throw new NotSupportedException(CompileOnly); } /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs index 9799f3f7..9601c2e4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs @@ -574,6 +574,40 @@ public class ScriptAnalysisServiceTests Assert.Contains("hello-sandbox", result.ConsoleOutput); } + [Fact] + public async Task RunInSandbox_LogGlobal_WritesToConsoleOutput() + { + // Flow Debugger step logs (design 2026-09-09 D4): the sandbox `Log` mirror exists so + // a script that logs BINDS in the Test Run editor, and its lines land in the console + // pane (the sandbox has no audit trail — nothing here is written to AuditLog). + var result = await _svc.RunInSandboxAsync( + new SandboxRunRequest( + "Log.Info(\"hello\"); return 1;", + Parameters: null, + TimeoutSeconds: null), + CancellationToken.None); + + Assert.True(result.Success, result.Error); + Assert.Contains("[info] hello", result.ConsoleOutput); + } + + [Fact] + public async Task RunInSandbox_InboundScript_LogGlobal_WritesToConsoleOutput() + { + // Same mirror on the inbound Test Run host (SandboxInboundScriptHost) — an API + // method script's Log.* calls must bind and surface in the console pane too. + var result = await _svc.RunInSandboxAsync( + new SandboxRunRequest( + "Log.Info(\"hello\"); return 1;", + Parameters: null, + TimeoutSeconds: null, + Kind: ScriptKind.InboundApi), + CancellationToken.None); + + Assert.True(result.Success, result.Error); + Assert.Contains("[info] hello", result.ConsoleOutput); + } + [Fact] public async Task RunInSandbox_ConcurrentRuns_DoNotCrossContaminateConsoleOutput() { @@ -661,6 +695,40 @@ public class ScriptAnalysisServiceTests Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA")); } + [Fact] + public void InboundScript_Log_DiagnosesClean() + { + // Flow Debugger step logs (design 2026-09-09 D4): `Log` is a shipped inbound-script + // global (InboundScriptContext.Log). The EDITOR host is a third mirror alongside the + // compile surface and the Test Run sandbox — without `Log` on InboundScriptHost a + // logging method script shows a spurious CS0103 while deploying and running fine. + var resp = _svc.Diagnose(new DiagnoseRequest( + Code: "Log.Info(\"x\");\n" + + "Log.Debug(\"d\");\n" + + "Log.Warn(\"w\", new System.Exception(\"boom\"));\n" + + "Log.Error(\"e\");", + Kind: ScriptKind.InboundApi)); + + Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS")); + Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA")); + } + + [Fact] + public void SiteScript_Log_DiagnosesClean() + { + // The other half of GlobalsTypeFor: every non-inbound kind analyses against + // SandboxScriptHost, so the same `Log` global must resolve there too — both at + // the top level and via `Instance.Log`. + var resp = _svc.Diagnose(new DiagnoseRequest( + Code: "Log.Info(\"x\");\n" + + "Log.Warn(\"w\", new System.Exception(\"boom\"));\n" + + "Instance.Log.Info(\"via instance\");", + Kind: ScriptKind.Template)); + + Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS")); + Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA")); + } + [Fact] public void InboundScript_Database_DiagnosesClean() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs index 90b2bcfb..ff4deb26 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs @@ -139,6 +139,25 @@ public class RoslynScriptCompilerTests Assert.Empty(diagnostics); } + [Fact] + public void CompilesCleanly_ForTheLogGlobal() + { + // Flow Debugger step logs (design 2026-09-09 D4): `Log` is a script-reachable + // global on the runtime ScriptGlobals AND on ScriptRuntimeContext, so both the + // top-level `Log` and `Instance.Log` must bind at design time — otherwise a + // script that logs would pass the editor but fail the deploy gate. + const string code = """ + Log.Info("a"); + Log.Warn("b", new System.Exception("x")); + Log.Error("c"); + Log.Debug("d"); + Instance.Log.Info("e"); + """; + + var diagnostics = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface)); + Assert.Empty(diagnostics); + } + [Fact] public void Compile_TwiceWithSharedResolver_StillCompilesCleanly() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs index 77c915ab..748c147d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs @@ -212,6 +212,24 @@ public class ScriptTrustValidatorTests Assert.Empty(ScriptTrustValidator.FindViolations(code)); } + [Fact] + public void Allows_TheLogGlobal() + { + // Flow Debugger step logs (design 2026-09-09 D4). The trust model is a DENY-list + // over API roots, not an allow-list of context members, so the `Log` global needs + // no policy entry — including the overload that attaches a System.Exception, which + // is a permitted BCL type. This pins that a logging script is not blocked. + const string code = """ + Log.Info("x"); + Log.Debug("d"); + Log.Warn("w", new System.Exception("boom")); + Log.Error("e"); + Instance.Log.Info("via instance"); + """; + + Assert.Empty(ScriptTrustValidator.FindViolations(code)); + } + [Fact] public void ForbiddenScopes_DoNotCover_TheCommonsScriptSurface() {