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
This commit is contained in:
Joseph Doherty
2026-10-22 17:51:35 -04:00
parent 17c7d05696
commit a9e8f5abfe
10 changed files with 305 additions and 34 deletions
@@ -0,0 +1,99 @@
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);
}
}
@@ -62,10 +62,34 @@ public sealed class MonacoInteropSourceTests
[Fact]
public void Markers_cross_to_dotnet_only_for_a_subscribed_problems_list()
{
Js().ShouldContain("if (e.reportMarkers) dotNetRef.invokeMethodAsync(\"OnMarkersChanged\"");
Js().ShouldContain("if (e.reportMarkers) {");
Read("Components", "Shared", "MonacoEditor.razor").ShouldContain("reportMarkers = MarkersChanged.HasDelegate");
}
[Fact]
public void The_markers_report_is_capped_before_it_crosses_the_circuit()
{
// Review I-1: the only OnMarkersChanged invoke sends the capped report, never the raw marker array.
var js = Js();
Regex.Matches(js, "\"OnMarkersChanged\"").Count.ShouldBe(1);
js.ShouldContain("dotNetRef.invokeMethodAsync(\"OnMarkersChanged\", report.markers, report.total)");
js.ShouldContain("const report = markerReport(e, markers);");
js.ShouldContain(".slice(0, max)");
js.ShouldContain("m.message.substring(0, maxChars - 1)");
// The caps come from the C# constants, so the JS and ScriptProblemReport.FromEditor agree.
var razor = Read("Components", "Shared", "MonacoEditor.razor");
razor.ShouldContain("maxReportedMarkers = ScriptEditorLimits.MaxReportedMarkers");
razor.ShouldContain("maxReportedMessageChars = ScriptEditorLimits.MaxReportedMessageChars");
razor.ShouldContain("ScriptProblemReport.FromEditor(markers, total)");
}
[Fact]
public void An_oversize_text_is_announced_at_blur_not_only_at_save()
{
Js().ShouldContain("invokeMethodAsync(\"OnSyncRefused\", serializedBytes(v))");
Read("Components", "Shared", "MonacoEditor.razor").ShouldContain("public void OnSyncRefused(int bytes)");
}
[Theory]
[InlineData("Components", "Pages", "ScriptEdit.razor")]
[InlineData("Components", "Shared", "Uns", "VirtualTagModal.razor")]