From da2578833dad86f5fcb148c8d1cb0bd2000e9793 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 17:38:56 -0400 Subject: [PATCH] fix(adminui): Monaco text syncs on demand, diagnostics are sequenced, Problems list (D1) R04:P1 - the editor no longer ships its full text JS->.NET on every keystroke. It reports at blur, and ScriptEdit / the VirtualTag modal read the authoritative value with MonacoEditor.GetValueAsync at Save (validation runs after the pull). The Blazor hub's MaximumReceiveMessageSize is raised from 32 KB to 1 MB. The JS refuses a value that would not fit instead of sending an oversize frame, and Save surfaces that refusal rather than persisting stale source. R04:P9 - diagnostics carry a generation counter and an AbortController. Every edit aborts the in-flight request, and only the response for the current text is applied. R04:S2 - the always-empty inlay-hints provider, endpoint, service method and contracts are removed. R04:S3 - MarkersChanged is typed (DiagnosticMarker) and feeds a new ScriptProblemsList. Clicking a problem calls RevealLineAsync. Markers cross to .NET only when a list subscribes. Refs #745 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../Components/Pages/ScriptEdit.razor | 28 ++++- .../Components/Shared/MonacoEditor.razor | 61 ++++++++-- .../Shared/ScriptProblemsList.razor | 57 +++++++++ .../Shared/Uns/VirtualTagModal.razor | 34 +++++- .../EndpointRouteBuilderExtensions.cs | 2 +- .../ScriptAnalysis/ScriptAnalysisContracts.cs | 4 - .../ScriptAnalysis/ScriptAnalysisEndpoints.cs | 1 - .../ScriptAnalysis/ScriptAnalysisService.cs | 5 - .../ScriptAnalysis/ScriptEditorLimits.cs | 19 +++ .../ScriptAnalysis/ScriptProblems.cs | 54 ++++++++ .../wwwroot/js/monaco-init.js | 115 ++++++++++++------ .../MonacoInteropSourceTests.cs | 80 ++++++++++++ .../ScriptAnalysis/MonacoSyncTests.cs | 56 +++++++++ .../ScriptAnalysis/ProblemsListTests.cs | 59 +++++++++ 14 files changed, 514 insertions(+), 61 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ScriptProblemsList.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptEditorLimits.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptProblems.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/MonacoInteropSourceTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/MonacoSyncTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ProblemsListTests.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 e0a1d8af..e6fa4123 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 @@ -11,6 +11,7 @@ @using System.Text @using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis @inject IDbContextFactory DbFactory @inject NavigationManager Nav @@ -30,7 +31,9 @@ else if (!IsNew && _existing is null) } else { - + @* OnSubmit (not OnValidSubmit): the editor text is pulled from Monaco FIRST, then validated — the bound + SourceCode is only refreshed at blur, so validating before the pull could judge stale text. *@ +
@@ -57,7 +60,9 @@ else
Source
- + +
SHA-256 hash is computed automatically on save.
@@ -84,6 +89,13 @@ else private bool _loaded; private bool _busy; private string? _error; + private MonacoEditor? _editor; + private IReadOnlyList _markers = Array.Empty(); + + private void OnMarkersChanged(IReadOnlyList markers) => _markers = markers; + + private Task RevealProblemAsync(DiagnosticMarker m) + => _editor is null ? Task.CompletedTask : _editor.RevealLineAsync(m.StartLineNumber, m.StartColumn); protected override async Task OnInitializedAsync() { @@ -105,11 +117,21 @@ else _loaded = true; } - private async Task SubmitAsync() + private async Task SubmitAsync(EditContext editContext) { _busy = true; _error = null; try { + // The editor reports its text only at blur, so read the authoritative current value now. A read + // failure (e.g. the script is too large to send) aborts the save rather than persisting stale source. + if (_editor is not null) + { + var read = await _editor.GetValueAsync(); + if (!read.Ok) { _error = $"Not saved: {read.Error}"; return; } + _form.SourceCode = read.Value!; + } + if (!editContext.Validate()) return; + var sourceHash = HashSource(_form.SourceCode); await using var db = await DbFactory.CreateDbContextAsync(); if (IsNew) 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 d4c73da1..82aab4a3 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 @@ -1,5 +1,6 @@ @namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared @using Microsoft.Extensions.Logging +@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis @implements IAsyncDisposable @inject IJSRuntime JS @inject Microsoft.Extensions.Logging.ILogger Logger @@ -22,7 +23,13 @@ style="height: @Height; border: 1px solid var(--bs-border-color); border-radius: 0.25rem; overflow: hidden;"> @code { + /// + /// The editor text. Two-way bound, but the editor reports edits back only when it loses focus — never per + /// keystroke (each report is one circuit message carrying the whole script). A page that persists the text + /// must read the authoritative current value with at save time. + /// [Parameter] public string Value { get; set; } = ""; + /// Raised with the editor text when the editor loses focus after an edit (and from ). [Parameter] public EventCallback ValueChanged { get; set; } [Parameter] public string Language { get; set; } = "csharp"; [Parameter] public string Height { get; set; } = "320px"; @@ -37,11 +44,11 @@ [Parameter] public string? EquipmentId { get; set; } /// - /// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic - /// debounce). The marker DTO is not modelled yet — typed as object[] until a - /// later task wires the Roslyn-backed diagnostics. + /// 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. /// - [Parameter] public EventCallback MarkersChanged { get; set; } + [Parameter] public EventCallback> MarkersChanged { get; set; } private ElementReference _hostRef; private DotNetObjectReference? _dotNetRef; @@ -69,7 +76,9 @@ value = Value ?? "", language = Language, readOnly = ReadOnly, - equipmentId = EquipmentId + equipmentId = EquipmentId, + maxSyncBytes = ScriptEditorLimits.MaxEditorSyncBytes, + reportMarkers = MarkersChanged.HasDelegate }, _dotNetRef); _initialized = true; @@ -126,9 +135,47 @@ return ValueChanged.InvokeAsync(_lastSentValue); } + /// + /// Reads the editor's current text on demand (call it at Save), raises when it + /// differs from the last value reported, and returns it. Before the editor is live (prerender, failed init) + /// the bound is authoritative and is returned as-is. + /// + /// The current text, or an error (e.g. the script is too large to send) — never a stale value. + public async Task GetValueAsync() + { + if (!_initialized) return new EditorReadResult(Value ?? "", null); + string? current; + try + { + current = await JS.InvokeAsync("MonacoBlazor.getValue", _id); + } + catch (JSDisconnectedException) + { + return new EditorReadResult(null, "The connection to the editor was lost; reload the page and try again."); + } + catch (JSException ex) + { + Logger.LogWarning(ex, "Monaco editor {EditorId}: failed to read the editor text.", _id); + // JSException.Message carries the JS stack after the first line; the author needs only the reason. + return new EditorReadResult(null, ex.Message.Split('\n', 2)[0].Trim()); + } + if (current is null) return new EditorReadResult(Value ?? "", null); // editor already disposed + await OnValueChanged(current); + return new EditorReadResult(current, null); + } + + /// The outcome of : the text, or why it could not be read. + /// The editor text; null when is set. + /// Why the text could not be read; null on success. + public sealed record EditorReadResult(string? Value, string? Error) + { + /// True when holds the editor text. + public bool Ok => Error is null; + } + [JSInvokable] - public Task OnMarkersChanged(object[] markers) => - MarkersChanged.InvokeAsync(markers ?? Array.Empty()); + public Task OnMarkersChanged(DiagnosticMarker[]? markers) => + MarkersChanged.InvokeAsync(markers ?? Array.Empty()); /// 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 new file mode 100644 index 00000000..c868a46a --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ScriptProblemsList.razor @@ -0,0 +1,57 @@ +@namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared +@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis +@* The script editor's Problems list. Fed by MonacoEditor.MarkersChanged; clicking a row calls OnSelect, which the + host wires to MonacoEditor.RevealLineAsync so the caret jumps to the problem. Ordering and wording live in + ScriptProblems (unit-tested). *@ + +@{ + var ordered = ScriptProblems.Order(Markers); +} +
+
+ Problems + @ScriptProblems.Summary(ordered) +
+ @if (ordered.Count > 0) + { +
    + @foreach (var m in ordered.Take(MaxRows)) + { +
  • + @ScriptProblems.SeverityLabel(m.Severity) + @m.StartLineNumber:@m.StartColumn + @m.Message + @m.Code +
  • + } +
+ @if (ordered.Count > MaxRows) + { +
… and @(ordered.Count - MaxRows) more
+ } + } +
+ +@code { + private const int MaxRows = 200; + + /// The markers the editor last reported (null before the first diagnostics pass). + [Parameter] public IReadOnlyList? Markers { get; set; } + + /// Raised when the author picks a problem; wire it to MonacoEditor.RevealLineAsync. + [Parameter] public EventCallback OnSelect { get; set; } + + private Task OnKeyAsync(KeyboardEventArgs e, DiagnosticMarker m) + => e.Key is "Enter" or " " ? OnSelect.InvokeAsync(m) : Task.CompletedTask; + + private static string BadgeClass(int severity) => severity switch + { + ScriptProblems.Error => "text-bg-danger", + ScriptProblems.Warning => "text-bg-warning", + _ => "text-bg-secondary", + }; +} 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 b28e4642..cbd7c0d4 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 @@ -7,6 +7,7 @@ @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Components.Forms @using ZB.MOM.WW.OtOpcUa.AdminUI.Uns +@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis @inject IUnsTreeService Svc @if (Visible) @@ -99,7 +100,9 @@ Editing shared script "@_scriptName" — used by @_scriptUsageCount virtual tag(s). Changes affect all of them. - + + @if (!string.IsNullOrWhiteSpace(_scriptError)) {
@_scriptError
@@ -217,6 +220,24 @@ private bool _scriptSaving; private bool _scriptSaved; private string? _scriptError; + private MonacoEditor? _scriptEditor; + private IReadOnlyList _scriptMarkers = Array.Empty(); + + private void OnScriptMarkersChanged(IReadOnlyList markers) => _scriptMarkers = markers; + + private Task RevealScriptProblemAsync(DiagnosticMarker m) + => _scriptEditor is null ? Task.CompletedTask : _scriptEditor.RevealLineAsync(m.StartLineNumber, m.StartColumn); + + // The inline editor reports its text only at blur; pull the authoritative current value before acting on it. + // Returns false (with _scriptError set) when the text could not be read, so nothing stale is saved. + private async Task PullScriptSourceAsync() + { + if (_scriptEditor is null) return true; + var read = await _scriptEditor.GetValueAsync(); + if (!read.Ok) { _scriptError = $"Not saved: {read.Error}"; return false; } + _scriptSource = read.Value!; + return true; + } // "New script" action state (shown only when no script is bound yet). private bool _scriptCreating; @@ -358,8 +379,16 @@ /// Toggles the inline script-source panel. Clears the stale "Script saved." banner when /// (re)expanding so a collapse/expand cycle doesn't re-show a confirmation from an earlier save. /// - private void ToggleScriptPanel() + private async Task ToggleScriptPanel() { + if (_scriptExpanded) + { + // Collapsing disposes the editor: keep its unsaved text in _scriptSource so re-opening shows it. + // A failed read keeps the panel open (with the error) rather than discarding the edits. + if (!await PullScriptSourceAsync()) return; + _scriptEditor = null; + _scriptMarkers = Array.Empty(); + } _scriptExpanded = !_scriptExpanded; if (_scriptExpanded) { @@ -384,6 +413,7 @@ _scriptError = null; try { + if (!await PullScriptSourceAsync()) return; var result = await Svc.UpdateScriptSourceAsync(_form.ScriptId, _scriptSource, _scriptRowVersion); if (result.Ok) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs index cfb8063e..1240a033 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs @@ -53,7 +53,7 @@ public static class EndpointRouteBuilderExtensions /// The same service collection, for chaining. public static IServiceCollection AddAdminUI(this IServiceCollection services) { - services.AddRazorComponents().AddInteractiveServerComponents(); + services.AddRazorComponents().AddInteractiveServerComponents().AddHubOptions(o => o.MaximumReceiveMessageSize = ScriptAnalysis.ScriptEditorLimits.HubMaximumReceiveMessageSize); services.AddOtOpcUaDriverStatusServices(); // Secrets-management UI (ZB.MOM.WW.Secrets.Ui RCL, mounted at /admin/secrets). Register its diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs index 2c71031c..1227957a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs @@ -22,7 +22,3 @@ public record SignatureHelpParameter(string Label, string? Documentation); public record FormatRequest(string Code); public record FormatResponse(string Code); - -public record InlayHintsRequest(string Code); -public record InlayHintsResponse(IReadOnlyList Hints); -public record InlayHint(int Line, int Column, string Label); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs index ad598c1f..50739298 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs @@ -27,7 +27,6 @@ public static class ScriptAnalysisEndpoints group.MapPost("/hover", (HoverRequest r, ScriptAnalysisService s, CancellationToken ct) => Run(() => s.Hover(r, ct), ct)); group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s, CancellationToken ct) => Run(() => Task.FromResult(s.SignatureHelp(r, ct)), ct)); group.MapPost("/format", (FormatRequest r, ScriptAnalysisService s) => Results.Ok(s.Format(r))); - group.MapPost("/inlay-hints", (InlayHintsRequest r, ScriptAnalysisService s) => Results.Ok(s.InlayHints(r))); return endpoints; } 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 9a98267d..6945c32f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -551,9 +551,4 @@ public sealed class ScriptAnalysisService return new FormatResponse(req.Code); } } - /// Returns editor inlay hints for the script. Currently a no-op (always empty) — the endpoint - /// exists for future use but the feature is not implemented. - /// The inlay-hints request (unused). - /// An always-empty inlay-hints response. - public InlayHintsResponse InlayHints(InlayHintsRequest req) => new(Array.Empty()); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptEditorLimits.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptEditorLimits.cs new file mode 100644 index 00000000..e2b89893 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptEditorLimits.cs @@ -0,0 +1,19 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; + +/// Size limits for moving Monaco editor text across the Blazor Server circuit. +/// +/// The editor text crosses JS → .NET only on demand (at blur and when a page saves), never per keystroke. Each +/// crossing is ONE SignalR message, bounded by the Blazor hub's MaximumReceiveMessageSize. The framework +/// default is 32 KB, which silently lost any larger script (the oversize invoke failed and Save persisted the +/// stale source), so AddAdminUI raises it to . The editor JS +/// refuses to send a value whose serialized form exceeds — an oversize frame +/// would tear the circuit down — and the save surfaces that refusal to the author instead. +/// +public static class ScriptEditorLimits +{ + /// The Blazor hub's receive limit, in bytes (1 MB). + public const long HubMaximumReceiveMessageSize = 1024 * 1024; + + /// The largest serialized editor value the JS will send, leaving 64 KB of frame headroom. + public const int MaxEditorSyncBytes = (int)HubMaximumReceiveMessageSize - 64 * 1024; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptProblems.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptProblems.cs new file mode 100644 index 00000000..6bd3ee05 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptProblems.cs @@ -0,0 +1,54 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; + +/// +/// Presentation rules for the script editor's Problems list — the markers Monaco reports through +/// MonacoEditor.MarkersChanged (the same shape the diagnostics endpoint +/// returns), rendered by ScriptProblemsList. +/// +public static class ScriptProblems +{ + /// Monaco's error severity. + public const int Error = 8; + + /// Monaco's warning severity. + public const int Warning = 4; + + /// Errors first, then warnings, then the rest; within a severity, by position. + /// The reported markers (null treated as none). + /// The markers in display order. + public static IReadOnlyList Order(IEnumerable? markers) + => (markers ?? Array.Empty()) + .OrderByDescending(m => m.Severity) + .ThenBy(m => m.StartLineNumber) + .ThenBy(m => m.StartColumn) + .ToList(); + + /// The label for a Monaco severity value. + /// 8 = error, 4 = warning, 2 = info, otherwise hint. + /// The display label. + public static string SeverityLabel(int severity) => severity switch + { + Error => "Error", + Warning => "Warning", + 2 => "Info", + _ => "Hint", + }; + + /// A one-line count, e.g. "2 errors, 1 warning". + /// The reported markers. + /// The summary, or "No problems". + public static string Summary(IReadOnlyCollection markers) + { + if (markers.Count == 0) return "No problems"; + var errors = markers.Count(m => m.Severity == Error); + var warnings = markers.Count(m => m.Severity == Warning); + var other = markers.Count - errors - warnings; + var parts = new List(3); + if (errors > 0) parts.Add(Plural(errors, "error")); + if (warnings > 0) parts.Add(Plural(warnings, "warning")); + if (other > 0) parts.Add($"{other} other"); + return string.Join(", ", parts); + } + + private static string Plural(int n, string noun) => n == 1 ? $"1 {noun}" : $"{n} {noun}s"; +} 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 5900b6a2..8a47002c 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 @@ -112,25 +112,6 @@ } catch (e) { return []; } } }); - monaco.languages.registerInlayHintsProvider("csharp", { - provideInlayHints: async function (model) { - try { - const resp = await fetch("/api/script-analysis/inlay-hints", { - method: "POST", credentials: "same-origin", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code: model.getValue() }) - }); - if (!resp.ok) return { hints: [], dispose: function () {} }; - const data = await resp.json(); - return { - hints: (data.hints || []).map(function (h) { - return { position: { lineNumber: h.line, column: h.column }, label: h.label, kind: 2, paddingRight: true }; - }), - dispose: function () {} - }; - } catch (e) { return { hints: [], dispose: function () {} }; } - } - }); monaco.languages.registerSignatureHelpProvider("csharp", { signatureHelpTriggerCharacters: ["(", ","], signatureHelpRetriggerCharacters: [","], @@ -156,17 +137,41 @@ }); } - async function fetchDiagnostics(model) { + // Resolves to the marker array, or null when the request was aborted (superseded by a newer edit) — the + // caller must then leave the markers alone. A server/network failure still resolves to [] (clears markers). + async function fetchDiagnostics(model, signal) { try { const resp = await fetch("/api/script-analysis/diagnostics", { - method: "POST", credentials: "same-origin", + method: "POST", credentials: "same-origin", signal: signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code: model.getValue(), equipmentId: equipmentIdOf(model) }) }); if (!resp.ok) return []; const data = await resp.json(); return data.markers || []; - } catch (e) { return []; } + } catch (e) { return (signal && signal.aborted) ? null : []; } + } + + // The editor text crosses the Blazor circuit (JS -> .NET) only on demand — at blur and when the page saves — + // never per keystroke. Each crossing is one SignalR message, bounded by the hub's MaximumReceiveMessageSize; + // an oversize message would tear the circuit down, so a value whose serialized form would not fit is refused + // here instead (getValue throws a descriptive error that .NET surfaces to the author). + // Blazor carries an interop value as a JSON string inside the hub frame; measuring it escaped twice is a + // conservative upper bound whichever hub protocol (BlazorPack or JSON) the circuit negotiated. + function serializedBytes(v) { return new TextEncoder().encode(JSON.stringify(JSON.stringify(v))).length; } + function tooLargeMessage(e, v) { + if (!e.maxSyncBytes) return null; + const bytes = serializedBytes(v); + return bytes > e.maxSyncBytes + ? "The script is too large for the editor to send to the server (" + bytes + " bytes; the limit is " + e.maxSyncBytes + " bytes)." + : null; + } + 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 + e.dirty = false; + e.dotNetRef.invokeMethodAsync("OnValueChanged", v).catch(function () { e.dirty = true; }); } // A single body-level node for Monaco's overflow widgets (suggest / hover / parameter hints). @@ -207,22 +212,38 @@ // Stamp the owning-equipment id on the model so the global csharp providers can thread it into the // {{equip}}/ completion + diagnostics (null on the shared ScriptEdit page). try { const m0 = editor.getModel(); if (m0) m0.__otEquipmentId = options.equipmentId || null; } catch (x) {} - let diagTimer = null; - const scheduleDiagnostics = function () { - if (diagTimer) clearTimeout(diagTimer); - diagTimer = setTimeout(async function () { - const model = editor.getModel(); if (!model) return; - const markers = await fetchDiagnostics(model); - monaco.editor.setModelMarkers(model, "otopcua", markers); - dotNetRef.invokeMethodAsync("OnMarkersChanged", markers).catch(function () {}); - }, 500); + const e = { + editor: editor, dotNetRef: dotNetRef, dirty: false, + maxSyncBytes: options.maxSyncBytes || 0, reportMarkers: !!options.reportMarkers, + diagTimer: null, diagGeneration: 0, diagAbort: null + }; + // Diagnostics: the 500 ms debounce delays the START of a request; the generation counter + AbortController + // make sure only the response for the CURRENT text is applied. Every edit bumps the generation and aborts + // the in-flight request, so a slow older response can never overwrite newer markers. + const runDiagnostics = async function () { + const model = editor.getModel(); if (!model) return; + const generation = ++e.diagGeneration; + const ac = new AbortController(); + e.diagAbort = ac; + const markers = await fetchDiagnostics(model, ac.signal); + 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 () {}); + }; + e.scheduleDiagnostics = function () { + e.diagGeneration++; + if (e.diagAbort) { e.diagAbort.abort(); e.diagAbort = null; } + if (e.diagTimer) clearTimeout(e.diagTimer); + e.diagTimer = setTimeout(runDiagnostics, 500); }; editor.onDidChangeModelContent(function () { - dotNetRef.invokeMethodAsync("OnValueChanged", editor.getValue()).catch(function () {}); - if (options.language === "csharp") scheduleDiagnostics(); + e.dirty = true; + if (options.language === "csharp") e.scheduleDiagnostics(); }); - editors[id] = { editor: editor, dotNetRef: dotNetRef }; - if (options.language === "csharp") scheduleDiagnostics(); + editor.onDidBlurEditorText(function () { syncValue(e); }); + editors[id] = e; + if (options.language === "csharp") e.scheduleDiagnostics(); } function setEditorOption(id, name, value) { const e = editors[id]; if (!e) return; @@ -235,10 +256,28 @@ const e = editors[id]; if (!e) return; e.editor.revealLineInCenter(line); e.editor.setPosition({ lineNumber: line, column: col || 1 }); e.editor.focus(); } - function setValue(id, v) { const e = editors[id]; if (e && e.editor.getValue() !== v) e.editor.setValue(v || ""); } - function getValue(id) { const e = editors[id]; return e ? e.editor.getValue() : null; } + function setValue(id, v) { + const e = editors[id]; if (!e) return; + if (e.editor.getValue() !== v) e.editor.setValue(v || ""); + e.dirty = false; // the text now equals what .NET holds + } + // Reads the current text on demand (Save). Throws when it would not fit in one circuit message. + function getValue(id) { + const e = editors[id]; if (!e) return null; + const v = e.editor.getValue(); + const err = tooLargeMessage(e, v); + if (err) throw new Error(err); + e.dirty = false; + return v; + } function setMarkers(id, m) { const e = editors[id]; const model = e && e.editor.getModel(); if (model) monaco.editor.setModelMarkers(model, "otopcua", m || []); } - function dispose(id) { const e = editors[id]; if (!e) return; try { e.editor.dispose(); } catch (x) {} delete editors[id]; } + function dispose(id) { + const e = editors[id]; if (!e) return; + if (e.diagTimer) clearTimeout(e.diagTimer); + if (e.diagAbort) e.diagAbort.abort(); + try { e.editor.dispose(); } catch (x) {} + delete editors[id]; + } window.MonacoBlazor = { createEditor, setValue, getValue, setMarkers, setEditorOption, format, revealLine, dispose }; })(); 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 new file mode 100644 index 00000000..0e7356cc --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/MonacoInteropSourceTests.cs @@ -0,0 +1,80 @@ +using System.Text.RegularExpressions; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis; + +/// +/// Source pins for the Monaco interop (AdminUI has no bUnit and no JS test runner). These do not prove the +/// browser behaviour — the docker-dev live check does — they stop the specific regressions R04:P1/P9/S2/S3 fixed +/// from quietly coming back: per-keystroke text sync, unsequenced diagnostics, the inlay-hints POST, and an +/// unconsumed marker round-trip. +/// +[Trait("Category", "Unit")] +public sealed class MonacoInteropSourceTests +{ + private static string Read(params string[] relative) + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) + dir = dir.Parent; + dir.ShouldNotBeNull("could not locate the repository root"); + return File.ReadAllText(Path.Combine(new[] { dir!.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.AdminUI" }.Concat(relative).ToArray())); + } + + private static string Js() => Read("wwwroot", "js", "monaco-init.js"); + + [Fact] + public void The_content_change_handler_does_not_ship_the_text_to_dotnet() + { + var js = Js(); + var handler = Regex.Match(js, @"onDidChangeModelContent\(function \(\) \{(?.*?)\n \}\);", RegexOptions.Singleline); + handler.Success.ShouldBeTrue("the content-change handler moved; update this pin"); + handler.Groups["body"].Value.ShouldNotContain("OnValueChanged"); + handler.Groups["body"].Value.ShouldNotContain("getValue"); + // Positive control: the text IS still reported — at blur. + js.ShouldContain("onDidBlurEditorText"); + js.ShouldContain("\"OnValueChanged\""); + } + + [Fact] + public void Diagnostics_are_generation_guarded_and_abortable() + { + var js = Js(); + js.ShouldContain("diagGeneration"); + js.ShouldContain("generation !== e.diagGeneration"); + js.ShouldContain("new AbortController()"); + js.ShouldContain("signal: signal"); + } + + [Fact] + public void Providers_honour_the_monaco_cancellation_token() + { + var js = Js(); + js.ShouldContain("token.onCancellationRequested"); + foreach (var path in new[] { "\"completions\"", "\"hover\"", "\"format\"", "\"signature-help\"" }) + js.ShouldContain("postAnalysis(" + path); + } + + [Fact] + public void The_inlay_hints_provider_is_not_registered() => Js().ShouldNotContain("registerInlayHintsProvider"); + + [Fact] + public void Markers_cross_to_dotnet_only_for_a_subscribed_problems_list() + { + Js().ShouldContain("if (e.reportMarkers) dotNetRef.invokeMethodAsync(\"OnMarkersChanged\""); + Read("Components", "Shared", "MonacoEditor.razor").ShouldContain("reportMarkers = MarkersChanged.HasDelegate"); + } + + [Theory] + [InlineData("Components", "Pages", "ScriptEdit.razor")] + [InlineData("Components", "Shared", "Uns", "VirtualTagModal.razor")] + public void Script_hosts_read_the_editor_on_save_and_render_the_problems_list(params string[] path) + { + var src = Read(path); + src.ShouldContain("GetValueAsync()"); + src.ShouldContain(").MakeGenericType(typeof(HubOptions<>).MakeGenericType(hubType)); + var options = sp.GetRequiredService(optionsType); + return (HubOptions)optionsType.GetProperty("Value")!.GetValue(options)!; + } + + [Fact] + public void The_blazor_hub_accepts_a_1_MB_message() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAdminUI(); + using var sp = services.BuildServiceProvider(); + + ComponentHubOptions(sp).MaximumReceiveMessageSize.ShouldBe(1024 * 1024); + ScriptEditorLimits.HubMaximumReceiveMessageSize.ShouldBe(1024 * 1024); + } + + [Fact] + public void Without_AddAdminUI_the_default_limit_is_the_32_KB_that_truncated_large_scripts() + { + // Positive control for the test above: the 1 MB comes from AddAdminUI, not from a framework default. + var services = new ServiceCollection(); + services.AddLogging(); + services.AddRazorComponents().AddInteractiveServerComponents(); + using var sp = services.BuildServiceProvider(); + + (ComponentHubOptions(sp).MaximumReceiveMessageSize ?? 32 * 1024).ShouldBe(32 * 1024); + } + + [Fact] + public void The_editor_sync_budget_leaves_headroom_inside_the_hub_limit_and_fits_a_100_KB_script() + { + ScriptEditorLimits.MaxEditorSyncBytes.ShouldBeLessThan((int)ScriptEditorLimits.HubMaximumReceiveMessageSize); + ScriptEditorLimits.MaxEditorSyncBytes.ShouldBeGreaterThan(4 * 100 * 1024); // 100 KB even after escaping twice + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ProblemsListTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ProblemsListTests.cs new file mode 100644 index 00000000..84c4deaf --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ProblemsListTests.cs @@ -0,0 +1,59 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis; + +/// +/// R04:S3 / S2 — the marker round-trip now feeds a real Problems list (MarkersChanged → list → RevealLineAsync), +/// and the dead inlay-hints endpoint is gone. The Razor wiring is live-verified; the ordering and wording it +/// renders are pinned here. +/// +public sealed class ProblemsListTests +{ + private static DiagnosticMarker M(int sev, int line, int col, string code = "X") => new(sev, line, col, line, col + 1, "msg", code); + + [Fact] + public void Problems_are_ordered_errors_first_then_by_position() + { + var ordered = ScriptProblems.Order(new[] + { + M(4, 1, 1, "W1"), M(8, 9, 2, "E3"), M(8, 2, 5, "E2"), M(8, 2, 1, "E1"), M(2, 1, 1, "I1"), + }); + + ordered.Select(m => m.Code).ShouldBe(new[] { "E1", "E2", "E3", "W1", "I1" }); + } + + [Fact] + public void Null_or_empty_markers_order_to_an_empty_list() => ScriptProblems.Order(null).ShouldBeEmpty(); + + [Theory] + [InlineData(8, "Error")] + [InlineData(4, "Warning")] + [InlineData(2, "Info")] + [InlineData(1, "Hint")] + public void Severity_labels_follow_monaco_values(int severity, string label) + => ScriptProblems.SeverityLabel(severity).ShouldBe(label); + + [Fact] + public void Summary_counts_errors_and_warnings() + { + ScriptProblems.Summary(Array.Empty()).ShouldBe("No problems"); + ScriptProblems.Summary(new[] { M(8, 1, 1) }).ShouldBe("1 error"); + ScriptProblems.Summary(new[] { M(8, 1, 1), M(8, 2, 1), M(4, 3, 1) }).ShouldBe("2 errors, 1 warning"); + ScriptProblems.Summary(new[] { M(4, 1, 1), M(4, 2, 1), M(2, 3, 1) }).ShouldBe("2 warnings, 1 other"); + } + + [Fact] + public void The_inlay_hints_endpoint_is_gone() + { + // The always-empty inlay-hints endpoint (and the JS provider that POSTed the full source to it on every + // edit) were removed. Positive control: the other five routes are still mapped. + var (app, _) = AnalysisCancellationTests.BuildApp(); + var routes = AnalysisCancellationTests.Endpoints(app).Select(e => e.RoutePattern.RawText!).ToList(); + + routes.ShouldNotContain(r => r.Contains("inlay", StringComparison.OrdinalIgnoreCase)); + foreach (var r in new[] { "/diagnostics", "/completions", "/hover", "/signature-help", "/format" }) + routes.ShouldContain(x => x.EndsWith(r)); + } +}