From a9e8f5abfee0721ba5e0cae20abe00c953a68477 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 17:51:35 -0400 Subject: [PATCH] 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 --- .../Components/Pages/ScriptEdit.razor | 6 +- .../Components/Shared/MonacoEditor.razor | 33 ++++++- .../Shared/ScriptProblemsList.razor | 17 ++-- .../Shared/Uns/VirtualTagModal.razor | 14 ++- .../ScriptAnalysis/ScriptAnalysisService.cs | 31 +++--- .../ScriptAnalysis/ScriptEditorLimits.cs | 22 +++++ .../ScriptAnalysis/ScriptProblems.cs | 49 +++++++++ .../wwwroot/js/monaco-init.js | 42 +++++++- .../ScriptAnalysis/MarkerPayloadBoundTests.cs | 99 +++++++++++++++++++ .../MonacoInteropSourceTests.cs | 26 ++++- 10 files changed, 305 insertions(+), 34 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/MarkerPayloadBoundTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor index e6fa4123..20190df1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor @@ -62,7 +62,7 @@ else
- +
SHA-256 hash is computed automatically on save.
@@ -90,9 +90,9 @@ else private bool _busy; private string? _error; private MonacoEditor? _editor; - private IReadOnlyList _markers = Array.Empty(); + private ScriptProblemReport _markers = ScriptProblemReport.Empty; - private void OnMarkersChanged(IReadOnlyList markers) => _markers = markers; + private void OnMarkersChanged(ScriptProblemReport report) => _markers = report; private Task RevealProblemAsync(DiagnosticMarker m) => _editor is null ? Task.CompletedTask : _editor.RevealLineAsync(m.StartLineNumber, m.StartColumn); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor index 82aab4a3..9c763b57 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor @@ -21,6 +21,14 @@
+@if (_tooLargeBytes > 0) +{ + @* Non-blocking: the author keeps editing, but learns now (not at Save) that this text cannot be sent. *@ +
+ This script is too large to send to the server (@_tooLargeBytes bytes; the limit is + @ScriptEditorLimits.MaxEditorSyncBytes bytes). It cannot be saved until it is shortened. +
+} @code { /// @@ -46,15 +54,18 @@ /// /// Fires whenever Monaco's diagnostic marker set updates (after the 500 ms debounce, and only for the response /// to the CURRENT text — superseded responses are dropped in the JS). Feeds ScriptProblemsList; the - /// markers are sent from JS only when this callback has a subscriber. + /// markers are sent from JS only when this callback has a subscriber, and at most + /// of them (plus the total) so the report always fits in one + /// circuit message. /// - [Parameter] public EventCallback> MarkersChanged { get; set; } + [Parameter] public EventCallback MarkersChanged { get; set; } private ElementReference _hostRef; private DotNetObjectReference? _dotNetRef; private readonly string _id = Guid.NewGuid().ToString("N"); private string _lastSentValue = ""; private bool _initialized; + private int _tooLargeBytes; private bool _wrap; private bool _minimap; private bool _dark; @@ -78,7 +89,9 @@ readOnly = ReadOnly, equipmentId = EquipmentId, maxSyncBytes = ScriptEditorLimits.MaxEditorSyncBytes, - reportMarkers = MarkersChanged.HasDelegate + reportMarkers = MarkersChanged.HasDelegate, + maxReportedMarkers = ScriptEditorLimits.MaxReportedMarkers, + maxReportedMessageChars = ScriptEditorLimits.MaxReportedMessageChars }, _dotNetRef); _initialized = true; @@ -160,6 +173,7 @@ return new EditorReadResult(null, ex.Message.Split('\n', 2)[0].Trim()); } if (current is null) return new EditorReadResult(Value ?? "", null); // editor already disposed + _tooLargeBytes = 0; // it was read, so it fits await OnValueChanged(current); return new EditorReadResult(current, null); } @@ -174,8 +188,17 @@ } [JSInvokable] - public Task OnMarkersChanged(DiagnosticMarker[]? markers) => - MarkersChanged.InvokeAsync(markers ?? Array.Empty()); + public Task OnMarkersChanged(DiagnosticMarker[]? markers, int total) => + MarkersChanged.InvokeAsync(ScriptProblemReport.FromEditor(markers, total)); + + /// The JS reports, at blur, that the text has grown past (bytes > 0) or back under (0) the sync limit. + [JSInvokable] + public void OnSyncRefused(int bytes) + { + if (_tooLargeBytes == bytes) return; + _tooLargeBytes = bytes; + StateHasChanged(); + } /// Programmatic scroll-to-line (called by the problems panel). public async Task RevealLineAsync(int line, int column = 1) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ScriptProblemsList.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ScriptProblemsList.razor index c868a46a..7ebd9e4a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ScriptProblemsList.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ScriptProblemsList.razor @@ -5,12 +5,13 @@ ScriptProblems (unit-tested). *@ @{ - var ordered = ScriptProblems.Order(Markers); + var report = Report ?? ScriptProblemReport.Empty; + var ordered = ScriptProblems.Order(report.Markers); }
Problems - @ScriptProblems.Summary(ordered) + @ScriptProblems.Summary(report with { Markers = ordered })
@if (ordered.Count > 0) { @@ -29,18 +30,20 @@ } - @if (ordered.Count > MaxRows) + @if (report.Total > Math.Min(ordered.Count, MaxRows)) { -
… and @(ordered.Count - MaxRows) more
+ @* The editor reports at most ScriptEditorLimits.MaxReportedMarkers problems (one circuit message); the + rest are still squiggled in the editor. *@ +
+@(report.Total - Math.Min(ordered.Count, MaxRows)) more — fix the problems above, or scroll the editor to see them all.
} }
@code { - private const int MaxRows = 200; + private const int MaxRows = ScriptEditorLimits.MaxReportedMarkers; - /// The markers the editor last reported (null before the first diagnostics pass). - [Parameter] public IReadOnlyList? Markers { get; set; } + /// The report the editor last sent (null before the first diagnostics pass). + [Parameter] public ScriptProblemReport? Report { get; set; } /// Raised when the author picks a problem; wire it to MonacoEditor.RevealLineAsync. [Parameter] public EventCallback OnSelect { get; set; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor index cbd7c0d4..789d6ccd 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor @@ -102,7 +102,7 @@ - + @if (!string.IsNullOrWhiteSpace(_scriptError)) {
@_scriptError
@@ -221,9 +221,9 @@ private bool _scriptSaved; private string? _scriptError; private MonacoEditor? _scriptEditor; - private IReadOnlyList _scriptMarkers = Array.Empty(); + private ScriptProblemReport _scriptMarkers = ScriptProblemReport.Empty; - private void OnScriptMarkersChanged(IReadOnlyList markers) => _scriptMarkers = markers; + private void OnScriptMarkersChanged(ScriptProblemReport report) => _scriptMarkers = report; private Task RevealScriptProblemAsync(DiagnosticMarker m) => _scriptEditor is null ? Task.CompletedTask : _scriptEditor.RevealLineAsync(m.StartLineNumber, m.StartColumn); @@ -248,6 +248,9 @@ if (!Visible) { _loadedKey = null; // closed → next open reloads fresh + // Closing removes the editor component: drop its reference and its problems. + _scriptEditor = null; + _scriptMarkers = ScriptProblemReport.Empty; return; } @@ -299,6 +302,9 @@ { _scriptSaved = false; _scriptError = null; + // A different script (or a reload) — drop the previous script's problems. The editor instance itself stays: + // while the panel is open the same MonacoEditor component receives the new source via its Value. + _scriptMarkers = ScriptProblemReport.Empty; if (string.IsNullOrEmpty(_form.ScriptId)) { @@ -387,7 +393,7 @@ // A failed read keeps the panel open (with the error) rather than discarding the edits. if (!await PullScriptSourceAsync()) return; _scriptEditor = null; - _scriptMarkers = Array.Empty(); + _scriptMarkers = ScriptProblemReport.Empty; } _scriptExpanded = !_scriptExpanded; if (_scriptExpanded) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs index 6945c32f..7182324c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -77,19 +77,14 @@ public sealed class ScriptAnalysisService return compilation; } - // This scope's current compilation (one per scoped service). Each analysis swaps its tree in with - // ReplaceSyntaxTree; a lost race between two concurrent calls on one scope is harmless because every - // compilation in the lineage is a complete, valid single-tree compilation. - private CSharpCompilation _compilation = BaseCompilation; - - // userSource is appended after Preamble, then the method/class are closed. - private (SyntaxTree Tree, CSharpCompilation Compilation, SemanticModel Model, int PreambleLength) Analyze( + // userSource is appended after Preamble, then the method/class are closed. Every analysis derives straight + // from the shared BaseCompilation: the analysis endpoints are minimal-API handlers, so a scoped service lives + // for one HTTP request and a per-instance lineage would never be more than one step long. + private static (SyntaxTree Tree, CSharpCompilation Compilation, SemanticModel Model, int PreambleLength) Analyze( string userSource, CancellationToken ct) { var tree = WrapAndParse(userSource, ct); - var current = _compilation; - var compilation = current.ReplaceSyntaxTree(current.SyntaxTrees.Single(), tree); - _compilation = compilation; + var compilation = BaseCompilation.ReplaceSyntaxTree(BaseCompilation.SyntaxTrees[0], tree); return (tree, compilation, compilation.GetSemanticModel(tree), Preamble.Length); } @@ -159,7 +154,7 @@ public sealed class ScriptAnalysisService foreach (var inv in SetVirtualTagCalls(tree.GetRoot(ct), preambleLength)) markers.Add(ToMarker(tree.GetMappedLineSpan(inv.Span), 4, SetVirtualTagInertMessage, "OTSCRIPT_SETVTAG_INERT")); - return new DiagnoseResponse(markers.Distinct().ToList()); + return new DiagnoseResponse(Bound(markers)); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { @@ -216,7 +211,7 @@ public sealed class ScriptAnalysisService $"reference named '{refName}'. Add a matching reference or correct the path.", "OTSCRIPT_EQUIPREF")); } - return new DiagnoseResponse(markers.Distinct().ToList()); + return new DiagnoseResponse(Bound(markers)); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { @@ -229,6 +224,18 @@ public sealed class ScriptAnalysisService } } + // Bounds what the diagnostics endpoint returns (and so what Monaco renders and the Problems list is fed from): + // de-duplicated, errors first then by position, at most MaxDiagnosticMarkers, each message cut to + // MaxDiagnosticMessageChars. Ordering before the cut keeps an error whenever one exists, so the editor still + // rejects exactly what publish rejects even for a pathological (e.g. non-C#) paste. + private static IReadOnlyList Bound(IEnumerable markers) + => ScriptProblems.Order(markers.Distinct()) + .Take(ScriptEditorLimits.MaxDiagnosticMarkers) + .Select(m => m.Message.Length <= ScriptEditorLimits.MaxDiagnosticMessageChars + ? m + : m with { Message = ScriptProblems.Truncate(m.Message, ScriptEditorLimits.MaxDiagnosticMessageChars) }) + .ToList(); + private const string SetVirtualTagInertMessage = "ctx.SetVirtualTag(...) has no effect at runtime: cross-tag writes are dropped in single-tag mode (the " + "cascade engine is not wired into the host). The script compiles and publishes, but this write will not " + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptEditorLimits.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptEditorLimits.cs index e2b89893..96fcbe66 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptEditorLimits.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptEditorLimits.cs @@ -16,4 +16,26 @@ public static class ScriptEditorLimits /// The largest serialized editor value the JS will send, leaving 64 KB of frame headroom. public const int MaxEditorSyncBytes = (int)HubMaximumReceiveMessageSize - 64 * 1024; + + /// + /// The most diagnostic markers the editor reports to .NET (for the Problems list) in one circuit message. + /// A non-C# paste produces several Roslyn errors per line; uncapped, a few thousand markers exceed the hub + /// limit and tear the circuit down. The JS sends the first N in display order (errors first) plus the total. + /// Monaco's own in-editor squiggles are local and stay uncapped (up to ). + /// + public const int MaxReportedMarkers = 200; + + /// Each reported marker's message is truncated to this many characters before it crosses the circuit. + public const int MaxReportedMessageChars = 300; + + /// + /// The most markers the diagnostics endpoint returns (errors first). Bounds the analysis response and the + /// marker set Monaco renders for pathological input; an error is always kept when one exists, so the + /// editor still rejects exactly what publish rejects. + /// + public const int MaxDiagnosticMarkers = 1000; + + /// Each diagnostics-endpoint marker message is truncated to this many characters (a Roslyn message + /// can quote an arbitrarily long pasted token). + public const int MaxDiagnosticMessageChars = 1000; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptProblems.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptProblems.cs index 6bd3ee05..5d3969ae 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptProblems.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptProblems.cs @@ -50,5 +50,54 @@ public static class ScriptProblems return string.Join(", ", parts); } + /// A one-line count for a (possibly capped) report, e.g. "200 errors (showing 200 of 4321)". + /// The report the editor sent. + /// The summary of the shown markers, with the shown/total suffix when some were left out. + public static string Summary(ScriptProblemReport report) + { + var summary = Summary(report.Markers.ToList()); + return report.Omitted > 0 ? $"{summary} (showing {report.Markers.Count} of {report.Total})" : summary; + } + + /// Cuts to at most characters, ending in an ellipsis. + /// The text. + /// The maximum length, ellipsis included (at least 1). + /// The text, unchanged when it already fits. + public static string Truncate(string text, int maxChars) + => text.Length <= maxChars ? text : string.Concat(text.AsSpan(0, Math.Max(0, maxChars - 1)), "…"); + private static string Plural(int n, string noun) => n == 1 ? $"1 {noun}" : $"{n} {noun}s"; } + +/// +/// What the editor reported for the Problems list: at most +/// markers in display order (errors first) plus the the diagnostics pass produced. The JS caps +/// the report before it crosses the circuit — one hub message, bounded by the 1 MB receive limit — and +/// clamps it again on arrival. +/// +/// The markers shown. +/// How many markers the diagnostics pass produced (≥ Markers.Count). +public sealed record ScriptProblemReport(IReadOnlyList Markers, int Total) +{ + /// No problems. + public static readonly ScriptProblemReport Empty = new(Array.Empty(), 0); + + /// How many markers were left out of . + public int Omitted => Math.Max(0, Total - Markers.Count); + + /// Builds a report from what the editor JS sent, re-applying the circuit caps defensively. + /// The markers sent (already capped by the JS). + /// The total the JS counted. + /// The clamped report. + public static ScriptProblemReport FromEditor(IReadOnlyList? markers, int total) + { + if (markers is null || markers.Count == 0) return total <= 0 ? Empty : new(Array.Empty(), total); + var shown = ScriptProblems.Order(markers) + .Take(ScriptEditorLimits.MaxReportedMarkers) + .Select(m => m.Message.Length <= ScriptEditorLimits.MaxReportedMessageChars + ? m + : m with { Message = ScriptProblems.Truncate(m.Message, ScriptEditorLimits.MaxReportedMessageChars) }) + .ToList(); + return new ScriptProblemReport(shown, Math.Max(total, markers.Count)); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js index 8a47002c..e5fc3f0e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js @@ -169,11 +169,44 @@ function syncValue(e) { if (!e.dirty) return; const v = e.editor.getValue(); - if (tooLargeMessage(e, v)) return; // stays dirty; the explicit save read reports the error + if (tooLargeMessage(e, v)) { + // Stays dirty; the explicit save read reports the error. Tell .NET once (a tiny message) so the + // author sees a non-blocking notice now rather than only at Save. + if (!e.tooLargeNotified) { + e.tooLargeNotified = true; + e.dotNetRef.invokeMethodAsync("OnSyncRefused", serializedBytes(v)).catch(function () {}); + } + return; + } + if (e.tooLargeNotified) { + e.tooLargeNotified = false; + e.dotNetRef.invokeMethodAsync("OnSyncRefused", 0).catch(function () {}); + } e.dirty = false; e.dotNetRef.invokeMethodAsync("OnValueChanged", v).catch(function () { e.dirty = true; }); } + // The Problems-list report is ONE circuit message too. A non-C# paste yields thousands of markers, so only the + // first maxReportedMarkers in display order (errors first, then by position — ScriptProblems.Order) cross, each + // message cut to maxReportedMessageChars, plus the total so the list can say how many were left out. + // Monaco's own squiggles (setModelMarkers) are local and stay uncapped. + function severityOrder(a, b) { + return (b.severity - a.severity) || (a.startLineNumber - b.startLineNumber) || (a.startColumn - b.startColumn); + } + function markerReport(e, markers) { + const max = e.maxReportedMarkers || 200, maxChars = e.maxReportedMessageChars || 300; + const shown = markers.slice().sort(severityOrder).slice(0, max).map(function (m) { + const msg = typeof m.message === "string" && m.message.length > maxChars + ? m.message.substring(0, maxChars - 1) + "\u2026" : m.message; + return { + severity: m.severity, startLineNumber: m.startLineNumber, startColumn: m.startColumn, + endLineNumber: m.endLineNumber, endColumn: m.endColumn, message: msg, + code: typeof m.code === "string" ? m.code.substring(0, 64) : m.code + }; + }); + return { markers: shown, total: markers.length }; + } + // A single body-level node for Monaco's overflow widgets (suggest / hover / parameter hints). // Rendering them here — outside any ancestor with a CSS transform (e.g. the theme's `.rise` // entrance animation) — keeps the position:fixed widgets viewport-correct. Without it, the @@ -215,6 +248,8 @@ const e = { editor: editor, dotNetRef: dotNetRef, dirty: false, maxSyncBytes: options.maxSyncBytes || 0, reportMarkers: !!options.reportMarkers, + maxReportedMarkers: options.maxReportedMarkers || 200, maxReportedMessageChars: options.maxReportedMessageChars || 300, + tooLargeNotified: false, diagTimer: null, diagGeneration: 0, diagAbort: null }; // Diagnostics: the 500 ms debounce delays the START of a request; the generation counter + AbortController @@ -229,7 +264,10 @@ if (markers === null || generation !== e.diagGeneration || editors[id] !== e) return; // superseded e.diagAbort = null; monaco.editor.setModelMarkers(model, "otopcua", markers); - if (e.reportMarkers) dotNetRef.invokeMethodAsync("OnMarkersChanged", markers).catch(function () {}); + if (e.reportMarkers) { + const report = markerReport(e, markers); + dotNetRef.invokeMethodAsync("OnMarkersChanged", report.markers, report.total).catch(function () {}); + } }; e.scheduleDiagnostics = function () { e.diagGeneration++; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/MarkerPayloadBoundTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/MarkerPayloadBoundTests.cs new file mode 100644 index 00000000..478395aa --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/MarkerPayloadBoundTests.cs @@ -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; + +/// +/// 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. +/// +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); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/MonacoInteropSourceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/MonacoInteropSourceTests.cs index 0e7356cc..179ec723 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/MonacoInteropSourceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/MonacoInteropSourceTests.cs @@ -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")]