feat(scripts): realign Test Run with runtime API, add anonymous-object calls and instance binding

The Test Run sandbox and Monaco analysis modelled a script API that had
drifted from the site runtime's ScriptGlobals, so real scripts failed to
compile in Test Run. Realign both to the runtime surface
(Instance/Scripts/ExternalSystem/Attributes/Children/Parent) and drop the
duplicate ScriptHost stub so the two cannot diverge again.

- Script calls (Scripts.CallShared, Instance.CallScript, Route.To().Call)
  accept an anonymous object instead of a hand-built dictionary, via a
  shared ScriptArgs normalizer; existing dictionary calls still compile.
- Test Run can optionally bind to a deployed instance, so Instance/
  Attributes/CallScript route to it cross-site; adds site-side
  RouteToGetAttributes/RouteToSetAttributes handlers.
- Adds Test Run panels to the API method and template script editors.
- Fixes the TestDatabaseQuery seed script, which queried a table that
  never existed.

Also commits unrelated in-progress work already in the tree: the health
monitoring report loop, site streaming changes, and the Admin/Design
data-connection and SMTP page reorganization.
This commit is contained in:
Joseph Doherty
2026-05-16 03:37:56 -04:00
parent d7b05b40e9
commit 295150751f
50 changed files with 2926 additions and 550 deletions

View File

@@ -7,7 +7,7 @@ public record AlarmStateChanged(
string AlarmName,
AlarmState State,
int Priority,
DateTimeOffset Timestamp)
DateTimeOffset Timestamp) : ISiteStreamEvent
{
/// <summary>
/// Severity level when <see cref="State"/> is <see cref="AlarmState.Active"/>.

View File

@@ -6,4 +6,4 @@ public record AttributeValueChanged(
string AttributeName,
object? Value,
string Quality,
DateTimeOffset Timestamp);
DateTimeOffset Timestamp) : ISiteStreamEvent;

View File

@@ -0,0 +1,10 @@
namespace ScadaLink.Commons.Messages.Streaming;
/// <summary>
/// Marker interface for events published to the site-wide stream
/// (attribute value changes and alarm state changes).
/// </summary>
public interface ISiteStreamEvent
{
string InstanceUniqueName { get; }
}

View File

@@ -0,0 +1,52 @@
using System.Collections;
using System.Reflection;
namespace ScadaLink.Commons.Types;
/// <summary>
/// Normalizes the loosely-typed <c>parameters</c> argument of a script call
/// (<c>Scripts.CallShared</c>, <c>Instance.CallScript</c>,
/// <c>Children["X"].CallScript</c>, <c>Parent.CallScript</c>,
/// <c>Route.To().Call</c>) into the dictionary the runtime carries.
///
/// Accepts: <c>null</c>; an existing dictionary; or any object whose public
/// properties become the parameter entries — so callers can pass an anonymous
/// object, <c>new { name = "Bob", count = 3 }</c>, instead of building a
/// <c>Dictionary&lt;string, object?&gt;</c> by hand.
/// </summary>
public static class ScriptArgs
{
public static IReadOnlyDictionary<string, object?>? Normalize(object? parameters)
{
switch (parameters)
{
case null:
return null;
case IReadOnlyDictionary<string, object?> roDict:
return roDict;
case IDictionary<string, object?> dict:
return new Dictionary<string, object?>(dict);
case IDictionary raw:
{
var result = new Dictionary<string, object?>();
foreach (DictionaryEntry entry in raw)
result[entry.Key?.ToString() ?? string.Empty] = entry.Value;
return result;
}
}
var type = parameters.GetType();
if (type.IsPrimitive || parameters is string or decimal)
throw new ArgumentException(
$"Script call parameters must be an object or dictionary, not {type.Name}.",
nameof(parameters));
var bag = new Dictionary<string, object?>();
foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (prop.GetIndexParameters().Length > 0) continue;
bag[prop.Name] = prop.GetValue(parameters);
}
return bag;
}
}