refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
+106
@@ -0,0 +1,106 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.ScriptAnalysis;
|
||||
|
||||
public class JsonSchemaShapeParserTests
|
||||
{
|
||||
// ── JSON Schema (post-migration) ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Parameters_JsonSchema_ScalarsAndRequired()
|
||||
{
|
||||
const string json = """
|
||||
{"type":"object","properties":{
|
||||
"id":{"type":"integer"},
|
||||
"label":{"type":"string"},
|
||||
"active":{"type":"boolean"}
|
||||
},"required":["id","active"]}
|
||||
""";
|
||||
var result = JsonSchemaShapeParser.ParseParameters(json);
|
||||
|
||||
Assert.Collection(result,
|
||||
p => { Assert.Equal("id", p.Name); Assert.Equal("Integer", p.Type); Assert.True(p.Required); },
|
||||
p => { Assert.Equal("label", p.Name); Assert.Equal("String", p.Type); Assert.False(p.Required); },
|
||||
p => { Assert.Equal("active", p.Name); Assert.Equal("Boolean", p.Type); Assert.True(p.Required); });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parameters_JsonSchema_ArrayOfStringsBecomesListString()
|
||||
{
|
||||
const string json = """
|
||||
{"type":"object","properties":{
|
||||
"tags":{"type":"array","items":{"type":"string"}}
|
||||
}}
|
||||
""";
|
||||
var result = JsonSchemaShapeParser.ParseParameters(json);
|
||||
|
||||
var tags = Assert.Single(result);
|
||||
Assert.Equal("tags", tags.Name);
|
||||
Assert.Equal("List<String>", tags.Type);
|
||||
Assert.False(tags.Required);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Return_JsonSchema_Number()
|
||||
{
|
||||
Assert.Equal("Float", JsonSchemaShapeParser.ParseReturnType(@"{""type"":""number""}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Return_JsonSchema_ArrayOfIntegers()
|
||||
{
|
||||
Assert.Equal("List<Integer>",
|
||||
JsonSchemaShapeParser.ParseReturnType(@"{""type"":""array"",""items"":{""type"":""integer""}}"));
|
||||
}
|
||||
|
||||
// ── Legacy flat shape (pre-migration safety net) ─────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Parameters_Legacy_FlatArrayStillParses()
|
||||
{
|
||||
const string json = """[{"name":"x","type":"Integer"},{"name":"y","type":"String","required":false}]""";
|
||||
var result = JsonSchemaShapeParser.ParseParameters(json);
|
||||
|
||||
Assert.Collection(result,
|
||||
p => { Assert.Equal("x", p.Name); Assert.Equal("Integer", p.Type); Assert.True(p.Required); },
|
||||
p => { Assert.Equal("y", p.Name); Assert.Equal("String", p.Type); Assert.False(p.Required); });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Return_Legacy_ListSentinelStillParses()
|
||||
{
|
||||
Assert.Equal("List<String>",
|
||||
JsonSchemaShapeParser.ParseReturnType(@"{""type"":""List"",""itemType"":""String""}"));
|
||||
}
|
||||
|
||||
// ── Edge cases ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Parameters_Null_ReturnsEmpty()
|
||||
{
|
||||
Assert.Empty(JsonSchemaShapeParser.ParseParameters(null));
|
||||
Assert.Empty(JsonSchemaShapeParser.ParseParameters(""));
|
||||
Assert.Empty(JsonSchemaShapeParser.ParseParameters(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parameters_Malformed_ReturnsEmpty()
|
||||
{
|
||||
Assert.Empty(JsonSchemaShapeParser.ParseParameters("{not json"));
|
||||
Assert.Empty(JsonSchemaShapeParser.ParseParameters("42"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Return_Null_ReturnsNull()
|
||||
{
|
||||
Assert.Null(JsonSchemaShapeParser.ParseReturnType(null));
|
||||
Assert.Null(JsonSchemaShapeParser.ParseReturnType(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parameters_SchemaWithNoProperties_ReturnsEmpty()
|
||||
{
|
||||
Assert.Empty(JsonSchemaShapeParser.ParseParameters(@"{""type"":""object""}"));
|
||||
Assert.Empty(JsonSchemaShapeParser.ParseParameters(@"{""type"":""object"",""properties"":{}}"));
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for the <c>SandboxConsoleCapture</c> writer that the Test Run
|
||||
/// sandbox installs on <c>Console.Out</c>/<c>Console.Error</c>. CentralUI-030
|
||||
/// surfaced an intra-script concurrency hazard: a sandboxed script can fan out
|
||||
/// work with <c>Task.WhenAll</c> / <c>Task.Run</c> and every child task inherits
|
||||
/// the capture <c>StringWriter</c> via <c>AsyncLocal</c>; <c>StringWriter</c> is
|
||||
/// not thread-safe, so concurrent writes could corrupt the buffer. These tests
|
||||
/// drive the writer the same way Roslyn-hosted user code does.
|
||||
/// </summary>
|
||||
public class SandboxConsoleCaptureTests
|
||||
{
|
||||
/// <summary>
|
||||
/// CentralUI-030: a capture scope shared across <c>Task.WhenAll</c> child
|
||||
/// tasks must serialise writes so the resulting transcript contains exactly
|
||||
/// the expected number of lines without character-level interleaving.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task BeginCapture_ConcurrentWritesFromTasks_DoNotCorruptBuffer()
|
||||
{
|
||||
// The static install routes Console.Out through the singleton sandbox
|
||||
// capture writer for the test process — this is idempotent and matches
|
||||
// the way ScriptAnalysisService bootstraps the sandbox in production.
|
||||
var (capture, _) = SandboxConsoleCapture.Install();
|
||||
|
||||
var buffer = new StringWriter();
|
||||
const int taskCount = 32;
|
||||
const int linesPerTask = 50;
|
||||
const int expectedLines = taskCount * linesPerTask;
|
||||
|
||||
using (capture.BeginCapture(buffer))
|
||||
{
|
||||
// AsyncLocal flows the capture scope into each Task.Run, mirroring
|
||||
// a sandboxed script doing `await Task.WhenAll(...)` over Tasks
|
||||
// that each `Console.WriteLine`.
|
||||
var tasks = Enumerable.Range(0, taskCount).Select(i => Task.Run(() =>
|
||||
{
|
||||
for (var j = 0; j < linesPerTask; j++)
|
||||
{
|
||||
Console.WriteLine($"task-{i}-line-{j}");
|
||||
}
|
||||
}));
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
var captured = buffer.ToString();
|
||||
// Without the lock, concurrent StringWriter.WriteLine can drop or
|
||||
// interleave characters and produce malformed lines / a wrong count.
|
||||
// We assert the exact line count and that every emitted token is
|
||||
// present on a line of its own — both fail under the unprotected
|
||||
// implementation.
|
||||
var lines = captured.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.Equal(expectedLines, lines.Length);
|
||||
|
||||
for (var i = 0; i < taskCount; i++)
|
||||
{
|
||||
for (var j = 0; j < linesPerTask; j++)
|
||||
{
|
||||
Assert.Contains($"task-{i}-line-{j}", lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanity check: the most basic capture happy-path still works after the
|
||||
/// CentralUI-030 lock was introduced.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BeginCapture_SingleThreadedWrites_AreCaptured()
|
||||
{
|
||||
var (capture, _) = SandboxConsoleCapture.Install();
|
||||
var buffer = new StringWriter();
|
||||
|
||||
using (capture.BeginCapture(buffer))
|
||||
{
|
||||
Console.WriteLine("hello");
|
||||
Console.Write("world");
|
||||
}
|
||||
|
||||
Assert.Contains("hello", buffer.ToString());
|
||||
Assert.Contains("world", buffer.ToString());
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for CentralUI-013. <c>ResolveCalledShape</c> resolved shared
|
||||
/// script shapes with <c>_sharedScripts.GetShapesAsync().GetAwaiter().GetResult()</c>
|
||||
/// — a sync-over-async block on a request thread that risks thread-pool
|
||||
/// starvation and deadlock. <c>Hover</c> and <c>SignatureHelp</c> were synchronous
|
||||
/// purely to accommodate that block. The fix makes both methods async and
|
||||
/// <c>await</c>s the catalog.
|
||||
/// </summary>
|
||||
public class ScriptAnalysisAsyncResolveTests
|
||||
{
|
||||
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;
|
||||
|
||||
public ScriptAnalysisAsyncResolveTests()
|
||||
{
|
||||
_catalog.GetShapesAsync().Returns(Array.Empty<ScriptShape>());
|
||||
_svc = new ScriptAnalysisService(_catalog, _cache, _services);
|
||||
}
|
||||
|
||||
private static ScriptShape Shape(string name, params ParameterShape[] ps) => new(name, ps, null);
|
||||
private static ParameterShape Param(string name, string type) => new(name, type, true);
|
||||
|
||||
[Fact]
|
||||
public async Task HoverAsync_OnSharedCallName_AwaitsCatalog_AndResolvesShape()
|
||||
{
|
||||
// The catalog only completes after yielding — a truly asynchronous
|
||||
// source. The fixed Hover awaits it instead of blocking.
|
||||
_catalog.GetShapesAsync().Returns(async _ =>
|
||||
{
|
||||
await Task.Yield();
|
||||
return (IReadOnlyList<ScriptShape>)new[]
|
||||
{
|
||||
Shape("Aggregate", Param("window", "Integer")),
|
||||
};
|
||||
});
|
||||
|
||||
var resp = await _svc.Hover(new HoverRequest(
|
||||
CodeText: "var r = Scripts.CallShared(\"Aggregate\");",
|
||||
Line: 1,
|
||||
Column: 30));
|
||||
|
||||
Assert.NotNull(resp.Markdown);
|
||||
Assert.Contains("shared script", resp.Markdown);
|
||||
Assert.Contains("Aggregate", resp.Markdown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignatureHelpAsync_InsideSharedCall_AwaitsCatalog_AndResolvesParameters()
|
||||
{
|
||||
_catalog.GetShapesAsync().Returns(async _ =>
|
||||
{
|
||||
await Task.Yield();
|
||||
return (IReadOnlyList<ScriptShape>)new[]
|
||||
{
|
||||
Shape("Aggregate", Param("window", "Integer"), Param("mode", "String")),
|
||||
};
|
||||
});
|
||||
|
||||
var resp = await _svc.SignatureHelp(new SignatureHelpRequest(
|
||||
CodeText: "var r = Scripts.CallShared(\"Aggregate\", ",
|
||||
Line: 1,
|
||||
Column: 41));
|
||||
|
||||
Assert.NotNull(resp.Label);
|
||||
Assert.Contains("Aggregate", resp.Label!);
|
||||
Assert.Equal(2, resp.Parameters!.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HoverAndSignatureHelp_AreAsync_NotSyncOverAsync()
|
||||
{
|
||||
// Structural guard: the methods must return Task so the catalog can be
|
||||
// awaited rather than blocked with .GetAwaiter().GetResult().
|
||||
var hover = typeof(ScriptAnalysisService).GetMethod(nameof(ScriptAnalysisService.Hover));
|
||||
var sigHelp = typeof(ScriptAnalysisService).GetMethod(nameof(ScriptAnalysisService.SignatureHelp));
|
||||
|
||||
Assert.NotNull(hover);
|
||||
Assert.NotNull(sigHelp);
|
||||
Assert.Equal(typeof(Task<HoverResponse>), hover!.ReturnType);
|
||||
Assert.Equal(typeof(Task<SignatureHelpResponse>), sigHelp!.ReturnType);
|
||||
}
|
||||
}
|
||||
+589
@@ -0,0 +1,589 @@
|
||||
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.Diagnostics")]
|
||||
[InlineData("System.Reflection")]
|
||||
[InlineData("System.Net")]
|
||||
public void ForbiddenUsing_AllBannedNamespaces(string ns)
|
||||
{
|
||||
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 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user