chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the source-hash keyed compile cache. Roslyn compilation is the most
|
||||
/// expensive step in the evaluator pipeline; this cache collapses redundant
|
||||
/// compiles of unchanged scripts to zero-cost lookups + makes sure concurrent
|
||||
/// callers never double-compile.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CompiledScriptCacheTests
|
||||
{
|
||||
private sealed class CompileCountingGate
|
||||
{
|
||||
public int Count;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void First_call_compiles_and_caches()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
cache.Count.ShouldBe(0);
|
||||
|
||||
var e = cache.GetOrCompile("""return 42;""");
|
||||
e.ShouldNotBeNull();
|
||||
cache.Count.ShouldBe(1);
|
||||
cache.Contains("""return 42;""").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Identical_source_returns_the_same_compiled_evaluator()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
var first = cache.GetOrCompile("""return 1;""");
|
||||
var second = cache.GetOrCompile("""return 1;""");
|
||||
ReferenceEquals(first, second).ShouldBeTrue();
|
||||
cache.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_source_produces_different_evaluator()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
var a = cache.GetOrCompile("""return 1;""");
|
||||
var b = cache.GetOrCompile("""return 2;""");
|
||||
ReferenceEquals(a, b).ShouldBeFalse();
|
||||
cache.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Whitespace_difference_misses_cache()
|
||||
{
|
||||
// Documented behavior: reformatting a script recompiles. Simpler + cheaper
|
||||
// than the alternative (AST-canonicalize then hash) and doesn't happen often.
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
cache.GetOrCompile("""return 1;""");
|
||||
cache.GetOrCompile("return 1; "); // trailing whitespace — different hash
|
||||
cache.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cached_evaluator_still_runs_correctly()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, double>();
|
||||
var e = cache.GetOrCompile("""return (double)ctx.GetTag("In").Value * 3.0;""");
|
||||
var ctx = new FakeScriptContext().Seed("In", 7.0);
|
||||
|
||||
// Run twice through the cache — both must return the same correct value.
|
||||
var first = await e.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||
var second = await cache.GetOrCompile("""return (double)ctx.GetTag("In").Value * 3.0;""")
|
||||
.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||
first.ShouldBe(21.0);
|
||||
second.ShouldBe(21.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_compile_is_evicted_so_retry_with_corrected_source_works()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
|
||||
// First attempt — undefined identifier, compile throws.
|
||||
Should.Throw<Exception>(() => cache.GetOrCompile("""return unknownIdentifier + 1;"""));
|
||||
cache.Count.ShouldBe(0, "failed compile must be evicted so retry can re-attempt");
|
||||
|
||||
// Retry with corrected source succeeds + caches.
|
||||
cache.GetOrCompile("""return 42;""").ShouldNotBeNull();
|
||||
cache.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_drops_every_entry()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
cache.GetOrCompile("""return 1;""");
|
||||
cache.GetOrCompile("""return 2;""");
|
||||
cache.Count.ShouldBe(2);
|
||||
|
||||
cache.Clear();
|
||||
cache.Count.ShouldBe(0);
|
||||
cache.Contains("""return 1;""").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Concurrent_compiles_of_the_same_source_deduplicate()
|
||||
{
|
||||
// LazyThreadSafetyMode.ExecutionAndPublication guarantees only one compile
|
||||
// even when multiple threads race GetOrCompile against an empty cache.
|
||||
// We can't directly count Roslyn compilations — but we can assert all
|
||||
// concurrent callers see the same evaluator instance.
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
const string src = """return 99;""";
|
||||
|
||||
var tasks = Enumerable.Range(0, 20)
|
||||
.Select(_ => Task.Run(() => cache.GetOrCompile(src)))
|
||||
.ToArray();
|
||||
Task.WhenAll(tasks).GetAwaiter().GetResult();
|
||||
|
||||
var firstInstance = tasks[0].Result;
|
||||
foreach (var t in tasks)
|
||||
ReferenceEquals(t.Result, firstInstance).ShouldBeTrue();
|
||||
cache.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_TContext_TResult_pairs_use_separate_cache_instances()
|
||||
{
|
||||
// Documented: each engine (virtual-tag / alarm-predicate / alarm-action) owns
|
||||
// its own cache. The type-parametric design makes this the default without
|
||||
// cross-contamination at the dictionary level.
|
||||
var intCache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
var boolCache = new CompiledScriptCache<FakeScriptContext, bool>();
|
||||
|
||||
intCache.GetOrCompile("""return 1;""");
|
||||
boolCache.GetOrCompile("""return true;""");
|
||||
|
||||
intCache.Count.ShouldBe(1);
|
||||
boolCache.Count.ShouldBe(1);
|
||||
intCache.Contains("""return true;""").ShouldBeFalse();
|
||||
boolCache.Contains("""return 1;""").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_source_throws_ArgumentNullException()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
Should.Throw<ArgumentNullException>(() => cache.GetOrCompile(null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the AST walker that extracts static tag dependencies from user scripts
|
||||
/// + rejects every form of non-literal path. Locks the parse shape the virtual-tag
|
||||
/// engine's change-trigger scheduler will depend on (Phase 7 plan Stream A.2).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DependencyExtractorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Extracts_single_literal_read()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""return ctx.GetTag("Line1/Speed").Value;""");
|
||||
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.ShouldContain("Line1/Speed");
|
||||
result.Writes.ShouldBeEmpty();
|
||||
result.Rejections.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extracts_multiple_distinct_reads()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
var a = ctx.GetTag("Line1/A").Value;
|
||||
var b = ctx.GetTag("Line1/B").Value;
|
||||
return (double)a + (double)b;
|
||||
""");
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.Count.ShouldBe(2);
|
||||
result.Reads.ShouldContain("Line1/A");
|
||||
result.Reads.ShouldContain("Line1/B");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deduplicates_identical_reads_across_the_script()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
if (((double)ctx.GetTag("X").Value) > 0)
|
||||
return ctx.GetTag("X").Value;
|
||||
return 0;
|
||||
""");
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.Count.ShouldBe(1);
|
||||
result.Reads.ShouldContain("X");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tracks_virtual_tag_writes_separately_from_reads()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
var v = (double)ctx.GetTag("InTag").Value;
|
||||
ctx.SetVirtualTag("OutTag", v * 2);
|
||||
return v;
|
||||
""");
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.ShouldContain("InTag");
|
||||
result.Writes.ShouldContain("OutTag");
|
||||
result.Reads.ShouldNotContain("OutTag");
|
||||
result.Writes.ShouldNotContain("InTag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_variable_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
var path = "Line1/Speed";
|
||||
return ctx.GetTag(path).Value;
|
||||
""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections.Count.ShouldBe(1);
|
||||
result.Rejections[0].Message.ShouldContain("string literal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_concatenated_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""return ctx.GetTag("Line1/" + "Speed").Value;""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections[0].Message.ShouldContain("string literal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_interpolated_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
var n = 1;
|
||||
return ctx.GetTag($"Line{n}/Speed").Value;
|
||||
""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections[0].Message.ShouldContain("string literal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_method_returned_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
string BuildPath() => "Line1/Speed";
|
||||
return ctx.GetTag(BuildPath()).Value;
|
||||
""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections[0].Message.ShouldContain("string literal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_empty_literal_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""return ctx.GetTag("").Value;""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections[0].Message.ShouldContain("empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_whitespace_only_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""return ctx.GetTag(" ").Value;""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ignores_non_ctx_method_named_GetTag()
|
||||
{
|
||||
// Scripts are free to define their own helper called "GetTag" — as long as it's
|
||||
// not on the ctx instance, the extractor doesn't pick it up. The sandbox
|
||||
// compile will still reject any path that isn't on the ScriptContext type.
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
string helper_GetTag(string p) => p;
|
||||
return helper_GetTag("NotATag");
|
||||
""");
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_source_is_a_no_op()
|
||||
{
|
||||
DependencyExtractor.Extract("").IsValid.ShouldBeTrue();
|
||||
DependencyExtractor.Extract(" ").IsValid.ShouldBeTrue();
|
||||
DependencyExtractor.Extract(null!).IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejection_carries_source_span_for_UI_pointing()
|
||||
{
|
||||
// Offending path at column 23-29 in the source — Admin UI uses Span to
|
||||
// underline the exact token.
|
||||
const string src = """return ctx.GetTag(path).Value;""";
|
||||
var result = DependencyExtractor.Extract(src);
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections[0].Span.Start.ShouldBeGreaterThan(0);
|
||||
result.Rejections[0].Span.Length.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_bad_paths_all_reported_in_one_pass()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
var p1 = "A"; var p2 = "B";
|
||||
return ctx.GetTag(p1).Value.ToString() + ctx.GetTag(p2).Value.ToString();
|
||||
""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nested_literal_GetTag_inside_expression_is_extracted()
|
||||
{
|
||||
// Supports patterns like ctx.GetTag("A") > ctx.GetTag("B") — both literal args
|
||||
// are captured even when the enclosing expression is complex.
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
return ((double)ctx.GetTag("A").Value) > ((double)ctx.GetTag("B").Value);
|
||||
""");
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.Count.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="ScriptContext"/> for tests. Holds a tag dictionary + a write
|
||||
/// log + a deterministic clock. Concrete subclasses in production will wire
|
||||
/// GetTag/SetVirtualTag through the virtual-tag engine + driver dispatch; here they
|
||||
/// hit a plain dictionary.
|
||||
/// </summary>
|
||||
public sealed class FakeScriptContext : ScriptContext
|
||||
{
|
||||
public Dictionary<string, DataValueSnapshot> Tags { get; } = new(StringComparer.Ordinal);
|
||||
public List<(string Path, object? Value)> Writes { get; } = [];
|
||||
|
||||
public override DateTime Now { get; } = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
public override ILogger Logger { get; } = new LoggerConfiguration().CreateLogger();
|
||||
|
||||
public override DataValueSnapshot GetTag(string path)
|
||||
{
|
||||
return Tags.TryGetValue(path, out var v)
|
||||
? v
|
||||
: new DataValueSnapshot(null, 0x80340000u, null, Now); // BadNodeIdUnknown
|
||||
}
|
||||
|
||||
public override void SetVirtualTag(string path, object? value)
|
||||
{
|
||||
Writes.Add((path, value));
|
||||
}
|
||||
|
||||
public FakeScriptContext Seed(string path, object? value,
|
||||
uint statusCode = 0u, DateTime? sourceTs = null)
|
||||
{
|
||||
Tags[path] = new DataValueSnapshot(value, statusCode, sourceTs ?? Now, Now);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the sink that mirrors script Error+ events to the main log at Warning
|
||||
/// level. Ensures script noise (Debug/Info/Warning) doesn't reach the main log
|
||||
/// while genuine script failures DO surface there so operators see them without
|
||||
/// watching a separate log file.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptLogCompanionSinkTests
|
||||
{
|
||||
private sealed class CapturingSink : ILogEventSink
|
||||
{
|
||||
public List<LogEvent> Events { get; } = [];
|
||||
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
|
||||
}
|
||||
|
||||
private static (ILogger script, CapturingSink scriptSink, CapturingSink mainSink) BuildPipeline()
|
||||
{
|
||||
// Main logger captures companion forwards.
|
||||
var mainSink = new CapturingSink();
|
||||
var mainLogger = new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose().WriteTo.Sink(mainSink).CreateLogger();
|
||||
|
||||
// Script logger fans out to scripts file (here: capture sink) + the companion sink.
|
||||
var scriptSink = new CapturingSink();
|
||||
var scriptLogger = new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose()
|
||||
.WriteTo.Sink(scriptSink)
|
||||
.WriteTo.Sink(new ScriptLogCompanionSink(mainLogger))
|
||||
.CreateLogger();
|
||||
|
||||
return (scriptLogger, scriptSink, mainSink);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Info_event_lands_in_scripts_sink_but_not_in_main()
|
||||
{
|
||||
var (script, scriptSink, mainSink) = BuildPipeline();
|
||||
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "Test").Information("just info");
|
||||
|
||||
scriptSink.Events.Count.ShouldBe(1);
|
||||
mainSink.Events.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Warning_event_lands_in_scripts_sink_but_not_in_main()
|
||||
{
|
||||
var (script, scriptSink, mainSink) = BuildPipeline();
|
||||
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "Test").Warning("just a warning");
|
||||
|
||||
scriptSink.Events.Count.ShouldBe(1);
|
||||
mainSink.Events.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Error_event_mirrored_to_main_at_Warning_level()
|
||||
{
|
||||
var (script, scriptSink, mainSink) = BuildPipeline();
|
||||
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "MyAlarm")
|
||||
.Error("condition script failed");
|
||||
|
||||
scriptSink.Events[0].Level.ShouldBe(LogEventLevel.Error);
|
||||
mainSink.Events.Count.ShouldBe(1);
|
||||
mainSink.Events[0].Level.ShouldBe(LogEventLevel.Warning, "Error+ is downgraded to Warning in the main log");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mirrored_event_includes_ScriptName_and_original_level()
|
||||
{
|
||||
var (script, _, mainSink) = BuildPipeline();
|
||||
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "HighTemp")
|
||||
.Error("temp exceeded limit");
|
||||
|
||||
var forwarded = mainSink.Events[0];
|
||||
forwarded.Properties.ShouldContainKey("ScriptName");
|
||||
((ScalarValue)forwarded.Properties["ScriptName"]).Value.ShouldBe("HighTemp");
|
||||
forwarded.Properties.ShouldContainKey("OriginalLevel");
|
||||
((ScalarValue)forwarded.Properties["OriginalLevel"]).Value.ShouldBe(LogEventLevel.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mirrored_event_preserves_exception_for_main_log_stack_trace()
|
||||
{
|
||||
var (script, _, mainSink) = BuildPipeline();
|
||||
var ex = new InvalidOperationException("user code threw");
|
||||
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "BadScript").Error(ex, "boom");
|
||||
|
||||
mainSink.Events.Count.ShouldBe(1);
|
||||
mainSink.Events[0].Exception.ShouldBeSameAs(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fatal_event_mirrored_just_like_Error()
|
||||
{
|
||||
var (script, _, mainSink) = BuildPipeline();
|
||||
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "Fatal_Script").Fatal("catastrophic");
|
||||
mainSink.Events.Count.ShouldBe(1);
|
||||
mainSink.Events[0].Level.ShouldBe(LogEventLevel.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_ScriptName_property_falls_back_to_unknown()
|
||||
{
|
||||
var (_, _, mainSink) = BuildPipeline();
|
||||
// Log without the ScriptName property to simulate a direct root-logger call
|
||||
// that bypassed the factory (defensive — shouldn't normally happen).
|
||||
var mainLogger = new LoggerConfiguration().CreateLogger();
|
||||
var companion = new ScriptLogCompanionSink(Log.Logger);
|
||||
|
||||
// Build an event manually so we can omit the property.
|
||||
var ev = new LogEvent(
|
||||
timestamp: DateTimeOffset.UtcNow,
|
||||
level: LogEventLevel.Error,
|
||||
exception: null,
|
||||
messageTemplate: new Serilog.Parsing.MessageTemplateParser().Parse("naked error"),
|
||||
properties: []);
|
||||
// Direct test: sink should not throw + message should be well-formed.
|
||||
Should.NotThrow(() => companion.Emit(ev));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_main_logger_rejected()
|
||||
{
|
||||
Should.Throw<ArgumentNullException>(() => new ScriptLogCompanionSink(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Custom_mirror_threshold_applied()
|
||||
{
|
||||
// Caller can raise the mirror threshold to Fatal if they want only
|
||||
// catastrophic events in the main log.
|
||||
var mainSink = new CapturingSink();
|
||||
var mainLogger = new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose().WriteTo.Sink(mainSink).CreateLogger();
|
||||
|
||||
var scriptLogger = new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose()
|
||||
.WriteTo.Sink(new ScriptLogCompanionSink(mainLogger, LogEventLevel.Fatal))
|
||||
.CreateLogger();
|
||||
|
||||
scriptLogger.ForContext(ScriptLoggerFactory.ScriptNameProperty, "X").Error("error");
|
||||
mainSink.Events.Count.ShouldBe(0, "Error below configured Fatal threshold — not mirrored");
|
||||
|
||||
scriptLogger.ForContext(ScriptLoggerFactory.ScriptNameProperty, "X").Fatal("fatal");
|
||||
mainSink.Events.Count.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the factory that creates per-script Serilog loggers with the
|
||||
/// <c>ScriptName</c> structured property pre-bound. The property is what lets
|
||||
/// Admin UI filter the scripts-*.log sink by which tag/alarm emitted each event.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptLoggerFactoryTests
|
||||
{
|
||||
/// <summary>Capturing sink that collects every emitted LogEvent for assertion.</summary>
|
||||
private sealed class CapturingSink : ILogEventSink
|
||||
{
|
||||
public List<LogEvent> Events { get; } = [];
|
||||
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_sets_ScriptName_structured_property()
|
||||
{
|
||||
var sink = new CapturingSink();
|
||||
var root = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger();
|
||||
var factory = new ScriptLoggerFactory(root);
|
||||
|
||||
var logger = factory.Create("LineRate");
|
||||
logger.Information("hello");
|
||||
|
||||
sink.Events.Count.ShouldBe(1);
|
||||
var ev = sink.Events[0];
|
||||
ev.Properties.ShouldContainKey(ScriptLoggerFactory.ScriptNameProperty);
|
||||
((ScalarValue)ev.Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("LineRate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Each_script_gets_its_own_property_value()
|
||||
{
|
||||
var sink = new CapturingSink();
|
||||
var root = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger();
|
||||
var factory = new ScriptLoggerFactory(root);
|
||||
|
||||
factory.Create("Alarm_A").Information("event A");
|
||||
factory.Create("Tag_B").Warning("event B");
|
||||
factory.Create("Alarm_A").Error("event A again");
|
||||
|
||||
sink.Events.Count.ShouldBe(3);
|
||||
((ScalarValue)sink.Events[0].Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("Alarm_A");
|
||||
((ScalarValue)sink.Events[1].Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("Tag_B");
|
||||
((ScalarValue)sink.Events[2].Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("Alarm_A");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Error_level_event_preserves_level_and_exception()
|
||||
{
|
||||
var sink = new CapturingSink();
|
||||
var root = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger();
|
||||
var factory = new ScriptLoggerFactory(root);
|
||||
|
||||
factory.Create("Test").Error(new InvalidOperationException("boom"), "script failed");
|
||||
|
||||
sink.Events[0].Level.ShouldBe(LogEventLevel.Error);
|
||||
sink.Events[0].Exception.ShouldBeOfType<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_root_rejected()
|
||||
{
|
||||
Should.Throw<ArgumentNullException>(() => new ScriptLoggerFactory(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_script_name_rejected()
|
||||
{
|
||||
var root = new LoggerConfiguration().CreateLogger();
|
||||
var factory = new ScriptLoggerFactory(root);
|
||||
Should.Throw<ArgumentException>(() => factory.Create(""));
|
||||
Should.Throw<ArgumentException>(() => factory.Create(" "));
|
||||
Should.Throw<ArgumentException>(() => factory.Create(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptNameProperty_constant_is_stable()
|
||||
{
|
||||
// Stability is an external contract — the Admin UI's log filter references
|
||||
// this exact string. If it changes, the filter breaks silently.
|
||||
ScriptLoggerFactory.ScriptNameProperty.ShouldBe("ScriptName");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Compiles scripts against the Phase 7 sandbox + asserts every forbidden API
|
||||
/// (HttpClient / File / Process / reflection) fails at compile, not at evaluation.
|
||||
/// Locks decision #6 — scripts can't escape to the broader .NET surface.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptSandboxTests
|
||||
{
|
||||
[Fact]
|
||||
public void Happy_path_script_compiles_and_returns()
|
||||
{
|
||||
// Baseline — ctx + Math + basic types must work.
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, double>.Compile(
|
||||
"""
|
||||
var v = (double)ctx.GetTag("X").Value;
|
||||
return Math.Abs(v) * 2.0;
|
||||
""");
|
||||
evaluator.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Happy_path_script_runs_and_reads_seeded_tag()
|
||||
{
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, double>.Compile(
|
||||
"""return (double)ctx.GetTag("In").Value * 2.0;""");
|
||||
|
||||
var ctx = new FakeScriptContext().Seed("In", 21.0);
|
||||
var result = await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||
result.ShouldBe(42.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetVirtualTag_records_the_write()
|
||||
{
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
ctx.SetVirtualTag("Out", 42);
|
||||
return 0;
|
||||
""");
|
||||
var ctx = new FakeScriptContext();
|
||||
await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||
ctx.Writes.Count.ShouldBe(1);
|
||||
ctx.Writes[0].Path.ShouldBe("Out");
|
||||
ctx.Writes[0].Value.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_File_IO_at_compile()
|
||||
{
|
||||
Should.Throw<ScriptSandboxViolationException>(() =>
|
||||
ScriptEvaluator<FakeScriptContext, string>.Compile(
|
||||
"""return System.IO.File.ReadAllText("c:/secrets.txt");"""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_HttpClient_at_compile()
|
||||
{
|
||||
Should.Throw<ScriptSandboxViolationException>(() =>
|
||||
ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
var c = new System.Net.Http.HttpClient();
|
||||
return 0;
|
||||
"""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_Process_Start_at_compile()
|
||||
{
|
||||
Should.Throw<ScriptSandboxViolationException>(() =>
|
||||
ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
System.Diagnostics.Process.Start("cmd.exe");
|
||||
return 0;
|
||||
"""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_Reflection_Assembly_Load_at_compile()
|
||||
{
|
||||
Should.Throw<ScriptSandboxViolationException>(() =>
|
||||
ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
System.Reflection.Assembly.Load("System.Core");
|
||||
return 0;
|
||||
"""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_Environment_GetEnvironmentVariable_at_compile()
|
||||
{
|
||||
// Environment lives in System.Private.CoreLib (allow-listed for primitives) —
|
||||
// BUT calling .GetEnvironmentVariable exposes process state we don't want in
|
||||
// scripts. In an allow-list sandbox this passes because mscorlib is allowed;
|
||||
// relying on ScriptSandbox alone isn't enough for the Environment class. We
|
||||
// document here that the CURRENT sandbox allows Environment — acceptable because
|
||||
// Environment doesn't leak outside the process boundary, doesn't side-effect
|
||||
// persistent state, and Phase 7 plan decision #6 targets File/Net/Process/
|
||||
// reflection specifically.
|
||||
//
|
||||
// This test LOCKS that compromise: operators should not be surprised if a
|
||||
// script reads an env var. If we later decide to tighten, this test flips.
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, string?>.Compile(
|
||||
"""return System.Environment.GetEnvironmentVariable("PATH");""");
|
||||
evaluator.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Script_exception_propagates_unwrapped()
|
||||
{
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""throw new InvalidOperationException("boom");""");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ctx_Now_is_available_without_DateTime_UtcNow_reaching_wall_clock()
|
||||
{
|
||||
// Scripts that need a timestamp go through ctx.Now so tests can pin it.
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, DateTime>.Compile("""return ctx.Now;""");
|
||||
evaluator.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deadband_helper_is_reachable_from_scripts()
|
||||
{
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, bool>.Compile(
|
||||
"""return ScriptContext.Deadband(10.5, 10.0, 0.3);""");
|
||||
evaluator.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Linq_Enumerable_is_available_from_scripts()
|
||||
{
|
||||
// LINQ is in the allow-list because SCADA math frequently wants Sum / Average
|
||||
// / Where. Confirm it works.
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
var nums = new[] { 1, 2, 3, 4, 5 };
|
||||
return nums.Where(n => n > 2).Sum();
|
||||
""");
|
||||
var result = await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken);
|
||||
result.ShouldBe(12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DataValueSnapshot_is_usable_in_scripts()
|
||||
{
|
||||
// ctx.GetTag returns DataValueSnapshot so scripts branch on quality.
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, bool>.Compile(
|
||||
"""
|
||||
var v = ctx.GetTag("T");
|
||||
return v.StatusCode == 0;
|
||||
""");
|
||||
var ctx = new FakeScriptContext().Seed("T", 5.0);
|
||||
var result = await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_error_gives_location_in_diagnostics()
|
||||
{
|
||||
// Compile errors must carry the source span so the Admin UI can point at them.
|
||||
try
|
||||
{
|
||||
ScriptEvaluator<FakeScriptContext, int>.Compile("""return fooBarBaz + 1;""");
|
||||
Assert.Fail("expected CompilationErrorException");
|
||||
}
|
||||
catch (CompilationErrorException ex)
|
||||
{
|
||||
ex.Diagnostics.ShouldNotBeEmpty();
|
||||
ex.Diagnostics[0].Location.ShouldNotBeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the per-evaluation timeout wrapper. Fast scripts complete normally;
|
||||
/// CPU-bound or hung scripts throw <see cref="ScriptTimeoutException"/> instead of
|
||||
/// starving the engine. Caller-supplied cancellation tokens take precedence over the
|
||||
/// timeout so driver-shutdown paths see a clean cancel rather than a timeout.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TimedScriptEvaluatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Fast_script_completes_under_timeout_and_returns_value()
|
||||
{
|
||||
var inner = ScriptEvaluator<FakeScriptContext, double>.Compile(
|
||||
"""return (double)ctx.GetTag("In").Value + 1.0;""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, double>(
|
||||
inner, TimeSpan.FromSeconds(1));
|
||||
|
||||
var ctx = new FakeScriptContext().Seed("In", 41.0);
|
||||
var result = await timed.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||
result.ShouldBe(42.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Script_longer_than_timeout_throws_ScriptTimeoutException()
|
||||
{
|
||||
// Scripts can't easily do Thread.Sleep in the sandbox (System.Threading.Thread
|
||||
// is denied). But a tight CPU loop exceeds any short timeout.
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
var end = Environment.TickCount64 + 5000;
|
||||
while (Environment.TickCount64 < end) { }
|
||||
return 1;
|
||||
""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(
|
||||
inner, TimeSpan.FromMilliseconds(50));
|
||||
|
||||
var ex = await Should.ThrowAsync<ScriptTimeoutException>(async () =>
|
||||
await timed.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
|
||||
ex.Timeout.ShouldBe(TimeSpan.FromMilliseconds(50));
|
||||
ex.Message.ShouldContain("50.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Caller_cancellation_takes_precedence_over_timeout()
|
||||
{
|
||||
// A CPU-bound script that would otherwise timeout; external ct fires first.
|
||||
// Expected: OperationCanceledException (not ScriptTimeoutException) so shutdown
|
||||
// paths aren't misclassified as timeouts.
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
var end = Environment.TickCount64 + 10000;
|
||||
while (Environment.TickCount64 < end) { }
|
||||
return 1;
|
||||
""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(
|
||||
inner, TimeSpan.FromSeconds(5));
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(80));
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
await timed.RunAsync(new FakeScriptContext(), cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_timeout_is_250ms_per_plan()
|
||||
{
|
||||
TimedScriptEvaluator<FakeScriptContext, int>.DefaultTimeout
|
||||
.ShouldBe(TimeSpan.FromMilliseconds(250));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zero_or_negative_timeout_is_rejected_at_construction()
|
||||
{
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile("""return 1;""");
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
new TimedScriptEvaluator<FakeScriptContext, int>(inner, TimeSpan.Zero));
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
new TimedScriptEvaluator<FakeScriptContext, int>(inner, TimeSpan.FromMilliseconds(-1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_inner_is_rejected()
|
||||
{
|
||||
Should.Throw<ArgumentNullException>(() =>
|
||||
new TimedScriptEvaluator<FakeScriptContext, int>(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_context_is_rejected()
|
||||
{
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile("""return 1;""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(inner);
|
||||
Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await timed.RunAsync(null!, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Script_exception_propagates_unwrapped()
|
||||
{
|
||||
// User-thrown exceptions must come through as-is — NOT wrapped in
|
||||
// ScriptTimeoutException. The virtual-tag engine catches them per-tag and
|
||||
// maps to BadInternalError; conflating with timeout would lose that info.
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""throw new InvalidOperationException("script boom");""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(inner, TimeSpan.FromSeconds(1));
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await timed.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldBe("script boom");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptTimeoutException_message_points_at_diagnostic_path()
|
||||
{
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
var end = Environment.TickCount64 + 5000;
|
||||
while (Environment.TickCount64 < end) { }
|
||||
return 1;
|
||||
""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(
|
||||
inner, TimeSpan.FromMilliseconds(30));
|
||||
|
||||
var ex = await Should.ThrowAsync<ScriptTimeoutException>(async () =>
|
||||
await timed.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("ctx.Logger");
|
||||
ex.Message.ShouldContain("widening the timeout");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user