d0af884760
MES alarm-status API §5.2 (docs/plans/2026-06-30-mes-alarm-status-api.md,
Phase 1 tasks 2-4). Site `Call` scripts had NO way to read alarm condition
state: the `Alarm` global exists only inside an on-trigger handler and
describes the one alarm that fired, and native mirrored conditions were
reachable only from the Debug View. That gap blocked the CvdReactor
SimpleAlarmStatus/AlarmStatus scripts entirely -- they cannot be written
without it. `Alarms.CurrentAsync()` closes it.
The data was already local: the script runs inside its own Instance Actor's
context, so this is a LOCAL Ask -- the same mechanism attribute reads use, no
cross-cluster hop. A dedicated GetAlarmSnapshotRequest/Response is used rather
than reusing DebugSnapshotRequest, which would materialise every attribute
value on every alarm poll; both are served from the same
BuildAlarmStatesSnapshot(), so the script view and the operator's Debug View
can never disagree.
Deliberate shape decisions:
- NOT scope-prefixed, unlike Attributes. Alarm identity is not a
scope-relative attribute name (computed alarms are keyed by configured
name, native conditions by a source-supplied reference), so prefixing
would hand a composed script a silently truncated list.
- Read-only. Native alarms are a read-only mirror of the source (no
ack-back), so no acknowledge/shelve operation is exposed.
- Placeholder rows are NOT pre-filtered: a caller must be able to tell
"binding configured and quiet" from "binding unknown". The documented
filter is `Active && !IsConfiguredPlaceholder`.
- ScriptAlarm lives in Commons so the runtime accessor and the compile-only
surface project to the SAME type -- a script binding at the design-time
gate binds identically at the site. Condition is the authority for
active/acked/severity, so one filter expression works across computed and
native alarms.
Mirrored on BOTH design-time surfaces. ScriptCompileSurface is covered by the
reflection parity guard (AlarmsAccessor added to its mirror pairs). The Central
UI Test-Run SandboxScriptHost is the third, hand-maintained mirror that the
parity test cannot reach (Central UI does not reference Site Runtime); without
it the design page would false-flag CS1061 on scripts the deploy gate accepts.
It throws a labelled ScriptSandboxException at run time -- there is no central
route to per-instance alarm state, and returning an empty list would read as
"nothing is in alarm", which is worse than an error.
ScriptTrustPolicy needs NO change, and the reason is structural rather than
incidental: the trust boundary is a deny-list over API roots, not an allow-list
of context members. A test pins that no ForbiddenScopes entry prefixes the
Commons script-surface namespace, so a future deny-list entry cannot silently
make ScriptAlarm untouchable.
Tests: 6 accessor cases (Ask contract, full native projection incl. AckTime,
unacked, computed-alarm derivation, placeholder visibility, scope-independence),
2 InstanceActor snapshot cases incl. equality with the Debug View row set, the
full MES script shape compiling against ScriptCompileSurface, 2 trust cases,
and a sandbox diagnose-clean case reading every projected ScriptAlarm field.
755 lines
32 KiB
C#
755 lines
32 KiB
C#
using Microsoft.Extensions.Caching.Memory;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.ScriptAnalysis;
|
|
|
|
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_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 < 12; round++)
|
|
{
|
|
for (var k = 0; k < 4; 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_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.
|
|
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" +
|
|
"return done && hit && pred && res.Matched;";
|
|
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" +
|
|
"return new { WasSuccessful = true, Alarms = rows };";
|
|
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"));
|
|
}
|
|
}
|