Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/MarkerPayloadBoundTests.cs
T
Joseph Doherty a9e8f5abfe fix(adminui): bound the diagnostics marker payload on both the endpoint and the circuit (D-a review r1)
I-1 - a non-C# paste produced thousands of Roslyn markers. The Problems-list report sent
them all as one JS->.NET message, which could exceed the 1 MB hub limit and kill the
circuit. The JS now sends at most MaxReportedMarkers (200) markers, errors first, each
message cut to 300 chars, plus the total. ScriptProblemReport.FromEditor clamps again
on arrival, and the Problems list shows "showing N of Total" / "+N more". The diagnostics
endpoint also bounds its response: Distinct, errors first, MaxDiagnosticMarkers (1000),
messages cut to 1000 chars. An error is always kept, so editor-accepts == publish-accepts.

M-1 - drop the per-instance compilation lineage; analyses derive from BaseCompilation.
M-2 - the VirtualTag modal clears its problems (and editor ref) on close and on a script
reload.
M-5 - an over-limit text is announced at blur (OnSyncRefused), not only at Save.

Refs #745

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 17:51:35 -04:00

100 lines
4.7 KiB
C#

using System.Text;
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
/// <summary>
/// Review D-a I-1 — the diagnostics markers cross the Blazor circuit (JS → .NET, for the Problems list) as ONE
/// hub message. A non-C# paste yields several Roslyn errors per line; uncapped, a few thousand markers exceed
/// the 1 MB hub limit and kill the circuit with every unsaved edit. Both the endpoint response and the circuit
/// report are now bounded; these tests pin the bounds on the C# side of the contract.
/// </summary>
public sealed class MarkerPayloadBoundTests
{
// ~150 KB of log-ish text: every line is several C# errors.
private static string NonCSharpPaste()
{
var sb = new StringBuilder();
for (var i = 0; sb.Length < 150 * 1024; i++)
sb.Append($"2026-10-22 12:00:{i % 60:00} INFO [worker-{i}] value={i}, state=ok; next -> {i + 1} @@ ##\n");
return sb.ToString();
}
[Fact]
public void A_non_csharp_paste_returns_a_bounded_marker_set_that_still_holds_the_errors()
{
var markers = new ScriptAnalysisService().Diagnose(new DiagnoseRequest(NonCSharpPaste())).Markers;
markers.Count.ShouldBe(ScriptEditorLimits.MaxDiagnosticMarkers); // positive control: the cap was hit
markers.ShouldAllBe(m => m.Message.Length <= ScriptEditorLimits.MaxDiagnosticMessageChars);
markers[0].Severity.ShouldBe(8); // errors first — publish rejects, so must the editor
markers.Select(m => (m.Severity, m.StartLineNumber, m.StartColumn))
.ShouldBe(ScriptProblems.Order(markers).Select(m => (m.Severity, m.StartLineNumber, m.StartColumn)));
}
[Fact]
public void A_long_quoted_token_has_its_message_truncated()
{
var giant = new string('x', 20_000);
var markers = new ScriptAnalysisService().Diagnose(new DiagnoseRequest($"return {giant};")).Markers;
markers.ShouldNotBeEmpty();
markers.ShouldAllBe(m => m.Message.Length <= ScriptEditorLimits.MaxDiagnosticMessageChars);
markers.ShouldContain(m => m.Message.EndsWith("…"));
}
[Fact]
public void A_small_script_is_not_truncated()
{
// Positive control for the cap: ordinary diagnostics pass through untouched.
var markers = new ScriptAnalysisService().Diagnose(new DiagnoseRequest("return ctx.Nope();")).Markers;
markers.ShouldContain(m => m.Severity == 8 && !m.Message.EndsWith("…"));
}
[Fact]
public void The_worst_case_circuit_report_fits_inside_the_sync_budget()
{
// The JS sends at most MaxReportedMarkers markers, each message cut to MaxReportedMessageChars. Worst case:
// control characters (\u0001 → 6 JSON bytes, 7 when escaped again) and the largest line/column numbers.
var message = new string('\u0001', ScriptEditorLimits.MaxReportedMessageChars);
var marker = new DiagnosticMarker(8, int.MaxValue, int.MaxValue, int.MaxValue, int.MaxValue, message, "OTSCRIPT_SETVTAG_INERT");
var report = Enumerable.Repeat(marker, ScriptEditorLimits.MaxReportedMarkers).ToArray();
var once = JsonSerializer.Serialize(new object[] { report, int.MaxValue });
var twice = Encoding.UTF8.GetByteCount(JsonSerializer.Serialize(once));
twice.ShouldBeLessThan(ScriptEditorLimits.MaxEditorSyncBytes);
ScriptEditorLimits.MaxEditorSyncBytes.ShouldBeLessThan((int)ScriptEditorLimits.HubMaximumReceiveMessageSize);
}
[Fact]
public void A_truncated_report_says_how_many_were_left_out()
{
var shown = Enumerable.Range(1, 3).Select(i => new DiagnosticMarker(8, i, 1, i, 2, "m", "CS1002")).ToArray();
var report = new ScriptProblemReport(shown, 4321);
report.Omitted.ShouldBe(4318);
ScriptProblems.Summary(report).ShouldBe("3 errors (showing 3 of 4321)");
// Positive control: an untruncated report carries no suffix.
ScriptProblems.Summary(new ScriptProblemReport(shown, 3)).ShouldBe("3 errors");
ScriptProblemReport.Empty.Omitted.ShouldBe(0);
}
[Fact]
public void A_report_from_js_is_clamped_even_if_the_js_cap_regressed()
{
var flood = Enumerable.Range(1, 5000).Select(i => new DiagnosticMarker(4, i, 1, i, 2, new string('m', 2000), "W")).ToArray();
var report = ScriptProblemReport.FromEditor(flood, total: 5000);
report.Markers.Count.ShouldBe(ScriptEditorLimits.MaxReportedMarkers);
report.Total.ShouldBe(5000);
report.Markers.ShouldAllBe(m => m.Message.Length <= ScriptEditorLimits.MaxReportedMessageChars);
ScriptProblemReport.FromEditor(null, 0).ShouldBe(ScriptProblemReport.Empty);
}
}