Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs
T
Joseph Doherty b3021e7c5f feat(scripts): read-only, allow-listed Files global for site scripts; Delmia test receiver parses recipe files
Script file access (design docs/plans/2026-09-28-script-file-access-design.md): Files.ReadTextAsync /
ReadLinesAsync / ExistsAsync on site ScriptGlobals (+ Instance.Files), backed by the site-singleton
ScriptFileReader. The one host-mediated exception to the System.IO ban - scripts still never name a
System.IO type. OFF by default: ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots (empty = every call
throws Disabled) + MaxFileBytes (1 MiB, validated 1..64 MiB, roots must be absolute). Guard: fully
qualified, no device paths / ADS, canonical path strictly inside a root (directory-boundary match), no
reparse point on the file or any directory below the root, size cap before and during the read.
Errors are one Commons type, ScriptFileAccessException(+ScriptFileAccessFailure); Commons.Types.Scripts
joins the default script imports on every surface so it binds by short name. Mirrored on
ScriptCompileSurface (CompileFiles, parity-pinned) and the Test Run sandbox (binds, refuses Disabled).
Inbound (central) scripts do not get it.

Rig: docker/recipes (sample RecipeValueName,RecipeValue files incl. an invalid one) bind-mounted
read-only at /app/recipes on both site-a nodes with AllowedRoots [/app/recipes]. The TestMesMachine
SimDelmiaRecipe simulator now reads + parses the downloaded file, converts each value to its recipe tag's
type and loads it all-or-nothing (14 new TestDelmiaReceiver attributes); verify-mes-test-machine.sh
group A covers accept / re-download / invalid lines / NotFound / OutsideAllowedRoots, VERIFY_GROUPS
selects groups (rig: AD).
2026-09-28 10:36:42 -04:00

833 lines
36 KiB
C#

using Microsoft.Extensions.Caching.Memory;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.ScriptAnalysis;
[Collection("Sandbox worker processes")]
public class ScriptAnalysisServiceTests
{
private readonly ISharedScriptCatalog _catalog = Substitute.For<ISharedScriptCatalog>();
private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 100 });
private readonly IServiceProvider _services = Substitute.For<IServiceProvider>();
private readonly ScriptAnalysisService _svc;
private static ScriptShape Shape(string name, params ParameterShape[] ps) =>
new(name, ps, null);
private static ParameterShape Param(string name, string type = "String", bool required = true) =>
new(name, type, required);
public ScriptAnalysisServiceTests()
{
_catalog.GetShapesAsync().Returns(Array.Empty<ScriptShape>());
_svc = new ScriptAnalysisService(_catalog, _cache, _services);
}
// ── Diagnose ──────────────────────────────────────────────────────────
[Fact]
public void EmptyCode_NoMarkers()
{
var resp = _svc.Diagnose(new DiagnoseRequest(""));
Assert.Empty(resp.Markers);
}
[Fact]
public void CleanScript_NoMarkers()
{
var resp = _svc.Diagnose(new DiagnoseRequest("var x = 1 + 2; return x;"));
Assert.Empty(resp.Markers);
}
[Fact]
public void MissingSemicolon_ReportsRoslynDiagnostic()
{
var resp = _svc.Diagnose(new DiagnoseRequest("var x = 1\n"));
Assert.Contains(resp.Markers, m => m.Code.StartsWith("CS"));
}
[Fact]
public void ForbiddenUsingDirective_RaisesSCADA001()
{
var resp = _svc.Diagnose(new DiagnoseRequest("using System.IO;"));
Assert.Contains(resp.Markers, m => m.Code == "SCADA001" && m.Message.Contains("System.IO"));
}
[Theory]
[InlineData("System.Reflection")]
[InlineData("System.Net")]
[InlineData("System.Threading")]
public void ForbiddenUsing_AllBannedNamespaces(string ns)
{
// M3.5: the editor deny-list is sourced from the shared ScriptTrustPolicy.
// System.Threading is now a forbidden root (only Tasks +
// CancellationToken(Source) are excepted).
var resp = _svc.Diagnose(new DiagnoseRequest($"using {ns};"));
Assert.Contains(resp.Markers, m => m.Code == "SCADA001" && m.Message.Contains(ns));
}
[Fact]
public void ForbiddenTypeUsage_ResolvesViaSemanticModel()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
"using System.IO; var s = File.ReadAllText(\"x\");"));
Assert.Contains(resp.Markers, m => m.Code == "SCADA002" && m.Message.Contains("File"));
}
[Fact]
public void SystemDiagnosticsNamespace_NoLongerWholesaleForbidden()
{
// M3.5: the unified policy forbids only System.Diagnostics.Process, not the
// whole System.Diagnostics namespace — so a bare `using System.Diagnostics;`
// is no longer flagged (Stopwatch & friends are allowed).
var resp = _svc.Diagnose(new DiagnoseRequest("using System.Diagnostics;"));
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA001");
}
[Fact]
public void StopwatchUsage_IsAllowedUnderUnifiedPolicy()
{
// System.Diagnostics.Stopwatch is NOT under the forbidden
// System.Diagnostics.Process scope — it must not be flagged.
var resp = _svc.Diagnose(new DiagnoseRequest(
"using System.Diagnostics; var sw = Stopwatch.StartNew(); return sw.ElapsedMilliseconds;"));
Assert.DoesNotContain(resp.Markers, m => m.Code is "SCADA001" or "SCADA002");
}
[Fact]
public void ProcessUsage_IsForbiddenUnderUnifiedPolicy()
{
// System.Diagnostics.Process IS the forbidden scope — flagged via the
// semantic marker even though the parent namespace is allowed.
var resp = _svc.Diagnose(new DiagnoseRequest(
"using System.Diagnostics; var p = Process.GetCurrentProcess(); return p.Id;"));
Assert.Contains(resp.Markers, m => m.Code == "SCADA002" && m.Message.Contains("Process"));
}
[Theory]
[InlineData("Monitor")]
[InlineData("Interlocked")]
[InlineData("Mutex")]
public void ThreadingPrimitives_NowFlagged_UnderUnifiedPolicy(string typeName)
{
// CentralUI's old deny-list allowed most of System.Threading (only Thread +
// Tasks.Sources were blocked). The unified policy forbids ALL of
// System.Threading except Tasks + CancellationToken(Source), so these
// previously-clean primitives are now flagged by the editor.
var resp = _svc.Diagnose(new DiagnoseRequest(
$"using System.Threading; var x = typeof({typeName}); return 1;"));
Assert.Contains(resp.Markers, m => m.Code is "SCADA001" or "SCADA002");
}
[Fact]
public void CancellationToken_StaysAllowed_UnderUnifiedPolicy()
{
// The AllowedExceptions carve-out keeps CancellationToken(Source) clean even
// though the rest of System.Threading is forbidden.
var resp = _svc.Diagnose(new DiagnoseRequest(
"var cts = new System.Threading.CancellationTokenSource(); return cts.Token.IsCancellationRequested;"));
Assert.DoesNotContain(resp.Markers, m => m.Code is "SCADA001" or "SCADA002");
}
[Fact]
public void UserIdentifierNamedFile_DoesNotFalsePositive()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
"var File = \"hello\"; return File.Length;"));
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA002");
}
[Fact]
public void UserIdentifierNamedThread_DoesNotFalsePositive()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
"var Thread = 42; return Thread;"));
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA002");
}
[Fact]
public void DiagnosticsAreCached_SecondCallSkipsRecompile()
{
var req = new DiagnoseRequest("using System.IO;");
var first = _svc.Diagnose(req);
var second = _svc.Diagnose(req);
Assert.Same(first, second);
}
[Fact]
public void DifferentCode_GetsDifferentCacheEntries()
{
var a = _svc.Diagnose(new DiagnoseRequest("var x = 1;"));
var b = _svc.Diagnose(new DiagnoseRequest("var y = 2;"));
Assert.NotSame(a, b);
}
[Fact]
public void UnknownParameterKey_RaisesSCADA003()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var x = Parameters[\"typo\"];",
DeclaredParameters: new[] { "name", "temperature" }));
Assert.Contains(resp.Markers, m => m.Code == "SCADA003" && m.Message.Contains("'typo'"));
}
[Fact]
public void DeclaredParameterKey_NoMarker()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var x = Parameters[\"name\"];",
DeclaredParameters: new[] { "name", "temperature" }));
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA003");
}
// ── Completions ───────────────────────────────────────────────────────
[Fact]
public async Task ParametersStringLiteral_ReturnsDeclaredParameterNames()
{
var req = new CompletionsRequest(
CodeText: "var x = Parameters[\"",
Line: 1,
Column: 21,
DeclaredParameters: new[] { "name", "temperature" });
var resp = await _svc.CompleteAsync(req);
Assert.Contains(resp.Items, i => i.Label == "name" && i.Detail == "declared parameter");
Assert.Contains(resp.Items, i => i.Label == "temperature");
}
[Fact]
public async Task CallScriptStringLiteral_ReturnsSiblingNamesWithSnippet()
{
var siblings = new[] { Shape("SiblingA", Param("x")) };
var req = new CompletionsRequest(
CodeText: "var x = Instance.CallScript(\"",
Line: 1,
Column: 30,
SiblingScripts: siblings);
var resp = await _svc.CompleteAsync(req);
var item = Assert.Single(resp.Items, i => i.Label == "SiblingA");
Assert.Equal(4, item.InsertTextRules);
// The runtime call API takes args as an anonymous object — the snippet
// emits one member per declared parameter.
Assert.Contains("new { x = ${1:x} }", item.InsertText);
Assert.Contains("sibling script", item.Detail);
}
[Fact]
public async Task CallSharedStringLiteral_ResolvesViaCatalogWithShapes()
{
_catalog.GetShapesAsync().Returns(new[]
{
Shape("GetWeather"),
Shape("Greet", Param("name"))
});
var req = new CompletionsRequest(
CodeText: "var x = Scripts.CallShared(\"",
Line: 1,
Column: 29);
var resp = await _svc.CompleteAsync(req);
// No-parameter shape: snippet just closes the call.
var weather = Assert.Single(resp.Items, i => i.Label == "GetWeather");
Assert.Equal("GetWeather\")", weather.InsertText);
// Parameterized shape: anonymous-object member per parameter.
var greet = Assert.Single(resp.Items, i => i.Label == "Greet");
Assert.Contains("new { name = ${1:name} }", greet.InsertText);
Assert.Contains("shared script", greet.Detail);
}
[Fact]
public async Task GeneralCompletion_ReturnsInScopeSymbols()
{
var req = new CompletionsRequest("var x = ", 1, 9);
var resp = await _svc.CompleteAsync(req);
// SandboxScriptHost globals are surfaced as in-scope symbols. The
// runtime call API is member-access — Scripts.CallShared / Instance.*
// — so the top-level globals are Parameters, Scripts, and Instance.
Assert.Contains(resp.Items, i => i.Label == "Parameters");
Assert.Contains(resp.Items, i => i.Label == "Scripts");
Assert.Contains(resp.Items, i => i.Label == "Instance");
}
// ── Hover ─────────────────────────────────────────────────────────────
[Fact]
public async Task Hover_OnSiblingName_ReturnsSignature()
{
var siblings = new[] { Shape("Calc", Param("x", "Integer"), Param("y", "Float")) };
// Cursor inside the "Calc" name literal of Instance.CallScript("Calc", ...).
var resp = await _svc.Hover(new HoverRequest(
CodeText: "var r = Instance.CallScript(\"Calc\", 1, 2);",
Line: 1,
Column: 32,
SiblingScripts: siblings));
Assert.NotNull(resp.Markdown);
Assert.Contains("sibling script", resp.Markdown);
Assert.Contains("Calc(x: Integer, y: Float): void", resp.Markdown);
}
[Fact]
public async Task Hover_OnUnrelatedToken_ReturnsNull()
{
var resp = await _svc.Hover(new HoverRequest(
CodeText: "var r = 1 + 2;",
Line: 1,
Column: 5));
Assert.Null(resp.Markdown);
}
// ── Signature help ────────────────────────────────────────────────────
[Fact]
public async Task SignatureHelp_InsideCallScript_ReturnsParameterStrip()
{
var siblings = new[] { Shape("Calc", Param("x", "Integer"), Param("y", "Float")) };
var resp = await _svc.SignatureHelp(new SignatureHelpRequest(
CodeText: "var r = Instance.CallScript(\"Calc\", 1, ",
Line: 1,
Column: 40,
SiblingScripts: siblings));
Assert.Equal("Instance.CallScript(\"Calc\", x: Integer, y: Float)", resp.Label);
Assert.Equal(2, resp.Parameters!.Count);
Assert.Equal("x: Integer", resp.Parameters[0].Label);
Assert.Equal("y: Float", resp.Parameters[1].Label);
Assert.Equal(1, resp.ActiveParameter);
}
[Fact]
public async Task SignatureHelp_OutsideCall_ReturnsNull()
{
var resp = await _svc.SignatureHelp(new SignatureHelpRequest(
CodeText: "var r = 1 + 2;",
Line: 1,
Column: 5));
Assert.Null(resp.Label);
}
// ── Format ────────────────────────────────────────────────────────────
[Fact]
public void Format_ScrambledCode_ReturnsPrettyPrinted()
{
var resp = _svc.Format(new FormatRequest("if(x){return 1;}else{return 2;}"));
// Roslyn's default formatter adds spaces around keywords/braces.
Assert.Contains("if (x)", resp.Code);
Assert.NotEqual("if(x){return 1;}else{return 2;}", resp.Code);
}
[Fact]
public void Format_EmptyCode_ReturnsEmpty()
{
Assert.Equal("", _svc.Format(new FormatRequest("")).Code);
}
// ── Self / Children / Parent attribute completions ────────────────────
private static AttributeShape Attr(string name, string type = "String") => new(name, type);
private static CompositionContext Comp(string name, AttributeShape[]? attrs = null, ScriptShape[]? scripts = null)
=> new(name, attrs ?? Array.Empty<AttributeShape>(), scripts ?? Array.Empty<ScriptShape>());
[Fact]
public async Task SelfAttribute_Literal_ReturnsSelfAttributeNames()
{
var req = new CompletionsRequest(
CodeText: "var x = Attributes[\"",
Line: 1,
Column: 21,
SelfAttributes: new[] { Attr("Temperature"), Attr("Setpoint", "Float") });
var resp = await _svc.CompleteAsync(req);
Assert.Contains(resp.Items, i => i.Label == "Temperature");
Assert.Contains(resp.Items, i => i.Label == "Setpoint" && i.Detail.Contains("Float"));
}
[Fact]
public async Task ChildAttribute_Literal_ReturnsChildAttributeNames()
{
var req = new CompletionsRequest(
CodeText: "var x = Children[\"TempSensor\"].Attributes[\"",
Line: 1,
Column: 44,
Children: new[] { Comp("TempSensor", attrs: new[] { Attr("Temperature"), Attr("Humidity") }) });
var resp = await _svc.CompleteAsync(req);
Assert.Contains(resp.Items, i => i.Label == "Temperature");
Assert.Contains(resp.Items, i => i.Label == "Humidity");
}
[Fact]
public async Task ParentAttribute_Literal_ReturnsParentAttributeNames()
{
var req = new CompletionsRequest(
CodeText: "var x = Parent.Attributes[\"",
Line: 1,
Column: 28,
Parent: Comp("Motor", attrs: new[] { Attr("SpeedRPM") }));
var resp = await _svc.CompleteAsync(req);
Assert.Contains(resp.Items, i => i.Label == "SpeedRPM");
}
[Fact]
public async Task ChildrenLiteral_ReturnsCompositionNames()
{
var req = new CompletionsRequest(
CodeText: "var x = Children[\"",
Line: 1,
Column: 19,
Children: new[] { Comp("TempSensor"), Comp("PressureSensor") });
var resp = await _svc.CompleteAsync(req);
Assert.Contains(resp.Items, i => i.Label == "TempSensor" && i.Detail == "composition");
Assert.Contains(resp.Items, i => i.Label == "PressureSensor");
}
[Fact]
public void UnknownSelfAttribute_RaisesSCADA006()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var x = Attributes[\"Typo\"];",
SelfAttributes: new[] { Attr("Temperature") }));
Assert.Contains(resp.Markers, m => m.Code == "SCADA006" && m.Message.Contains("Typo"));
}
[Fact]
public void KnownSelfAttribute_NoMarker()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var x = Attributes[\"Temperature\"];",
SelfAttributes: new[] { Attr("Temperature") }));
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA006");
}
[Fact]
public void UnknownChildAttribute_RaisesSCADA006()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var x = Children[\"TempSensor\"].Attributes[\"Typo\"];",
Children: new[] { Comp("TempSensor", attrs: new[] { Attr("Temperature") }) }));
Assert.Contains(resp.Markers, m => m.Code == "SCADA006" && m.Message.Contains("TempSensor"));
}
[Fact]
public void UnknownComposition_RaisesSCADA007()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var x = Children[\"Unknown\"].Attributes[\"X\"];",
Children: new[] { Comp("TempSensor") }));
Assert.Contains(resp.Markers, m => m.Code == "SCADA007" && m.Message.Contains("Unknown"));
}
[Fact]
public async Task ChildrenCallScript_ReturnsChildScripts()
{
var req = new CompletionsRequest(
CodeText: "var x = Children[\"TempSensor\"].CallScript(\"",
Line: 1,
Column: 44,
Children: new[]
{
Comp("TempSensor", scripts: new[] { Shape("Sample", Param("count", "Integer")) })
});
var resp = await _svc.CompleteAsync(req);
var sample = Assert.Single(resp.Items, i => i.Label == "Sample");
Assert.Contains("script on TempSensor", sample.Detail);
Assert.Contains("${1:count}", sample.InsertText);
}
[Fact]
public async Task ParentCallScript_ReturnsParentScripts()
{
var req = new CompletionsRequest(
CodeText: "var x = Parent.CallScript(\"",
Line: 1,
Column: 28,
Parent: Comp("Motor", scripts: new[] { Shape("Trip") }));
var resp = await _svc.CompleteAsync(req);
Assert.Contains(resp.Items, i => i.Label == "Trip" && i.Detail.Contains("parent script"));
}
// ── Hover on Parameters["name"] ───────────────────────────────────────
[Fact]
public async Task Hover_OnParametersKey_ShowsDeclaredType()
{
var resp = await _svc.Hover(new HoverRequest(
CodeText: "var x = Parameters[\"name\"];",
Line: 1,
Column: 22,
DeclaredParameters: new[] { new ParameterShape("name", "String", true) }));
Assert.NotNull(resp.Markdown);
Assert.Contains("name", resp.Markdown);
Assert.Contains("String", resp.Markdown);
}
// ── CentralUI-001: trust-model gate before sandbox execution ──────────
[Fact]
public void Diagnose_FullyQualifiedForbiddenCall_RaisesSCADA002()
{
// A forbidden API reached by fully-qualified name (no `using`, no bare
// type identifier) must still be flagged — the pre-fix semantic check
// only inspected the leftmost identifier and missed this shape.
var resp = _svc.Diagnose(new DiagnoseRequest(
"var d = System.IO.Directory.GetCurrentDirectory(); return d;"));
Assert.Contains(resp.Markers, m => m.Code == "SCADA002");
}
[Fact]
public async Task RunInSandbox_FullyQualifiedForbiddenApi_IsBlockedBeforeExecution()
{
// Regression test for CentralUI-001. RunInSandboxAsync used to execute any
// script that compiled, with no trust-model enforcement — so fully-qualified
// forbidden API code ran in the central host process. The fix gates execution
// on the forbidden-API analysis.
var result = await _svc.RunInSandboxAsync(
new SandboxRunRequest(
"var d = System.IO.Directory.GetCurrentDirectory(); return d;",
Parameters: null,
TimeoutSeconds: null),
CancellationToken.None);
Assert.False(result.Success);
Assert.Equal(SandboxErrorKind.CompileError, result.ErrorKind);
Assert.Contains("trust model", result.Error);
Assert.NotNull(result.Markers);
Assert.Contains(result.Markers!, m => m.Code is "SCADA001" or "SCADA002");
}
[Fact]
public async Task RunInSandbox_ForbiddenUsingDirective_IsBlockedBeforeExecution()
{
var result = await _svc.RunInSandboxAsync(
new SandboxRunRequest(
"using System.Diagnostics; var p = Process.GetCurrentProcess().Id; return p;",
Parameters: null,
TimeoutSeconds: null),
CancellationToken.None);
Assert.False(result.Success);
Assert.Equal(SandboxErrorKind.CompileError, result.ErrorKind);
}
[Fact]
public async Task RunInSandbox_CleanScript_StillRuns()
{
// The gate must not block a script that stays within the allowed surface.
var result = await _svc.RunInSandboxAsync(
new SandboxRunRequest("return 21 * 2;", Parameters: null, TimeoutSeconds: null),
CancellationToken.None);
Assert.True(result.Success);
Assert.Equal("42", result.ReturnValueJson);
}
[Fact]
public void NotifyOutboxShape_DiagnosesClean()
{
// Notification Outbox: the sandbox Notify surface must be
// signature-faithful to production NotifyHelper/NotifyTarget —
// Send returns Task<string> (a NotificationId) and Status takes that
// id. A script using the new shape must compile clean in the sandbox,
// exactly as it would against the real site runtime.
var resp = _svc.Diagnose(new DiagnoseRequest(
"var id = await Notify.To(\"ops\").Send(\"subj\", \"body\"); " +
"var st = await Notify.Status(id); " +
"return st.Status;"));
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS"));
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA"));
}
[Fact]
public async Task RunInSandbox_NotifyOutboxShape_StillRuns()
{
// The new Notify shape must also run end-to-end in the no-op sandbox:
// Send yields a fake NotificationId, Status yields a placeholder
// NotificationDeliveryStatus.
var result = await _svc.RunInSandboxAsync(
new SandboxRunRequest(
"var id = await Notify.To(\"ops\").Send(\"subj\", \"body\"); " +
"var st = await Notify.Status(id); " +
"return st.Status;",
Parameters: null,
TimeoutSeconds: null),
CancellationToken.None);
Assert.True(result.Success);
Assert.Equal("\"Unknown\"", result.ReturnValueJson);
}
[Fact]
public async Task RunInSandbox_CapturesConsoleOutput()
{
var result = await _svc.RunInSandboxAsync(
new SandboxRunRequest(
"System.Console.WriteLine(\"hello-sandbox\"); return 1;",
Parameters: null,
TimeoutSeconds: null),
CancellationToken.None);
Assert.True(result.Success);
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()
{
// Regression test for CentralUI-003. RunInSandboxAsync used to redirect the
// process-global Console.Out/Error to a per-call StringWriter. While one run
// is mid-flight, any concurrent run's `finally` restores Console.Out to the
// ORIGINAL writer — so the long run loses every Console.WriteLine it makes
// after that point, and short runs cross-contaminate each other. The fix
// routes capture per-call via an AsyncLocal writer without mutating
// process-global Console state.
// A long-running script: writes its tag, then burns CPU, then writes again,
// repeatedly. While it spins, many short runs start and finish around it.
async Task<string> RunLong()
{
var code = @"
for (int i = 0; i < 40; i++)
{
System.Console.WriteLine(""LONG"");
long acc = 0;
for (long j = 0; j < 2_000_000; j++) acc += j;
System.Console.WriteLine(""LONG"" + acc);
}
return 0;";
var r = await _svc.RunInSandboxAsync(
new SandboxRunRequest(code, Parameters: null, TimeoutSeconds: 30),
CancellationToken.None);
Assert.True(r.Success, r.Error);
return r.ConsoleOutput;
}
async Task<string> RunShort(int id)
{
var code = $"for (int i = 0; i < 30; i++) System.Console.WriteLine(\"S{id}\"); return 0;";
var r = await _svc.RunInSandboxAsync(
new SandboxRunRequest(code, Parameters: null, TimeoutSeconds: 30),
CancellationToken.None);
Assert.True(r.Success, r.Error);
return r.ConsoleOutput;
}
var longTask = RunLong();
var shortTasks = new List<Task<string>>();
for (var round = 0; round < 1; round++)
{
for (var k = 0; k < 3; k++)
shortTasks.Add(RunShort(round * 4 + k));
await Task.Yield();
}
var longOut = await longTask;
var shortOuts = await Task.WhenAll(shortTasks);
// The long run must have captured ALL 80 of its own writes (40 plain + 40 acc).
var longLines = longOut.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Count(l => l.StartsWith("LONG"));
Assert.Equal(80, longLines);
// No short run's output must have leaked into the long run's capture.
for (var i = 0; i < shortOuts.Length; i++)
Assert.DoesNotContain($"S{i}", longOut);
// Each short run captured exactly its own 30 lines and nothing else.
for (var i = 0; i < shortOuts.Length; i++)
{
var lines = shortOuts[i].Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(30, lines.Length);
Assert.All(lines, l => Assert.Equal($"S{i}", l.Trim()));
}
}
// ── Inbound-script analysis surface ──────────────────────────────────
[Fact]
public void InboundScript_WaitForAttribute_DiagnosesClean()
{
// WaitForAttribute is a shipped inbound-script helper. The editor must
// not flag it as an error: the InboundScriptHost mirror must expose the
// method so Roslyn resolves it during static analysis.
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var ok = await Route.To(\"site-a\").WaitForAttribute(\"Flag\", true, System.TimeSpan.FromSeconds(5));",
Kind: ScriptKind.InboundApi));
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS"));
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()
{
// InboundAPI-026/030: the scoped Database helper is a shipped inbound-script
// capability. The editor must not flag its async read/write API as an error —
// the InboundScriptHost mirror must expose QuerySingleAsync/QueryAsync/ExecuteAsync
// with signatures matching the runtime helper so Roslyn resolves them.
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var code = await Database.QuerySingleAsync<string>(\"BTDB\", \"SELECT Code FROM Machine WHERE SAPID=@s\", new { s = (string)Parameters[\"sap\"] });\n"
+ "var rows = await Database.QueryAsync(\"BTDB\", \"SELECT Code FROM Machine\");\n"
+ "var n = await Database.ExecuteAsync(\"BTDB\", \"UPDATE Machine SET Code=@c WHERE SAPID=@s\", new { c = code, s = \"131453\" });",
Kind: ScriptKind.InboundApi));
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS"));
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA"));
}
// ── Instance-script batch/wait surface (follow-up #7) ─────────────────
[Fact]
public void InstanceScript_BatchAndWaitHelpers_DiagnoseClean()
{
// Follow-up #7: SandboxAttributeAccessor was missing WriteBatchAndWaitAsync /
// WaitAsync / WaitForAsync, so the editor false-flagged valid instance scripts
// with CS1061 even though `template validate` and the deploy gate accept them.
// The sandbox surface must mirror the runtime AttributeAccessor member-for-member.
// Gitea #36: also covers the reset-aware WriteBatchAndWaitAsync overload
// (writes+waits a reset key/value before the batch) with and without the
// optional resetTimeout, returning a HandshakeResult.
var code =
"var data = new System.Collections.Generic.Dictionary<string, object?> { { \"Cmd\", 1 } };\n" +
"var done = await Attributes.WriteBatchAndWaitAsync(data, \"Flag\", true, \"Done\", true, System.TimeSpan.FromSeconds(5));\n" +
"var hit = await Attributes.WaitAsync(\"Done\", true, System.TimeSpan.FromSeconds(5));\n" +
"var pred = await Attributes.WaitAsync(\"Done\", v => v != null, System.TimeSpan.FromSeconds(5));\n" +
"var res = await Attributes.WaitForAsync(\"Done\", true, System.TimeSpan.FromSeconds(5));\n" +
"var hs = await Attributes.WriteBatchAndWaitAsync(data, \"Flag\", true, \"Done\", true, System.TimeSpan.FromSeconds(5), \"Done\", false, System.TimeSpan.FromSeconds(2));\n" +
"var hs2 = await Attributes.WriteBatchAndWaitAsync(data, \"Flag\", true, \"Done\", true, System.TimeSpan.FromSeconds(5), \"Done\", false);\n" +
"return done && hit && pred && res.Matched && hs.Matched && !hs2.ResetFailed;";
var resp = _svc.Diagnose(new DiagnoseRequest(code));
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS"));
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA"));
}
[Fact]
public void ChildInstanceScript_WriteBatchAndWait_DiagnoseClean()
{
// The same helper on a child composition
// (Children["X"].Attributes.WriteBatchAndWaitAsync) — the exact shape used by
// the base MESReceiver MoveIn/MoveOut scripts — must also resolve cleanly
// through the sandbox composition accessor.
var code =
"var data = new System.Collections.Generic.Dictionary<string, object?> { { \"MoveInType\", \"Run\" } };\n" +
"var ok = await Children[\"RightMESReceiver\"].Attributes.WriteBatchAndWaitAsync(" +
"data, \"MoveInFlag\", true, \"MoveInCompleteFlag\", true, System.TimeSpan.FromSeconds(25));\n" +
"return ok;";
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: code,
Children: new[] { Comp("RightMESReceiver", attrs: new[] { Attr("MoveInType") }) }));
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS"));
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA"));
}
// ── Alarms read surface (MES alarm-status API §5.2) ───────────────────
[Fact]
public void InstanceScript_AlarmsCurrentAsync_DiagnoseClean()
{
// SandboxScriptHost is the THIRD hand-maintained mirror of the runtime globals
// (Central UI does not reference Site Runtime, so the reflection parity test in
// SiteRuntime.Tests cannot cover it). Without `Alarms` here the editor would
// false-flag CS1061 on the MES alarm-status scripts even though the deploy gate
// accepts them. Every ScriptAlarm field those scripts project is read below, so a
// rename on the record surfaces here rather than in the design page.
var code =
"var alarms = await Alarms.CurrentAsync();\n" +
"var rows = alarms\n" +
" .Where(a => a.Active && !a.IsConfiguredPlaceholder)\n" +
" .Where(a => a.NativeSourceCanonicalName.StartsWith(\"Left\"))\n" +
" .Where(a => a.Severity >= 900 && a.Severity <= 999)\n" +
" .Select(a => new {\n" +
" Name = a.Name,\n" +
" Description = string.IsNullOrEmpty(a.Message) ? a.AlarmTypeName : a.Message,\n" +
" StatusCode = a.Acknowledged ? \"Triggered.Acked\" : \"Triggered\",\n" +
" TriggeredDT = a.OriginalRaiseTime ?? a.Timestamp,\n" +
" AckDT = a.AckTime,\n" +
" AckComment = a.OperatorComment })\n" +
" .ToList();\n" +
"var recipe = await Files.ReadLinesAsync(@\"C:\\recipes\\r.txt\");\n" +
"var recipeThere = await Instance.Files.ExistsAsync(@\"C:\\recipes\\r.txt\");\n" +
"var r = await Alarms.MesReportableAsync();\n" +
"var f = r.Where(a => a.IsMesReportable).ToList();\n" +
"return new { WasSuccessful = true, Alarms = rows, Flagged = f.Count };";
var resp = _svc.Diagnose(new DiagnoseRequest(code));
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS"));
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA"));
}
}