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")]