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
@@ -62,7 +62,7 @@ else
<div style="padding:1rem">
<MonacoEditor @ref="_editor" @bind-Value="_form.SourceCode" Height="420px"
MarkersChanged="OnMarkersChanged" />
<ScriptProblemsList Markers="_markers" OnSelect="RevealProblemAsync" />
<ScriptProblemsList Report="_markers" OnSelect="RevealProblemAsync" />
<div class="form-text">SHA-256 hash is computed automatically on save.</div>
</div>
</section>
@@ -90,9 +90,9 @@ else
private bool _busy;
private string? _error;
private MonacoEditor? _editor;
private IReadOnlyList<DiagnosticMarker> _markers = Array.Empty<DiagnosticMarker>();
private ScriptProblemReport _markers = ScriptProblemReport.Empty;
private void OnMarkersChanged(IReadOnlyList<DiagnosticMarker> 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);
@@ -21,6 +21,14 @@
<div @ref="_hostRef" class="monaco-editor-host"
style="height: @Height; border: 1px solid var(--bs-border-color); border-radius: 0.25rem; overflow: hidden;"></div>
@if (_tooLargeBytes > 0)
{
@* Non-blocking: the author keeps editing, but learns now (not at Save) that this text cannot be sent. *@
<div class="text-warning small mt-1" role="status">
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.
</div>
}
@code {
/// <summary>
@@ -46,15 +54,18 @@
/// <summary>
/// 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 <c>ScriptProblemsList</c>; 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
/// <see cref="ScriptEditorLimits.MaxReportedMarkers"/> of them (plus the total) so the report always fits in one
/// circuit message.
/// </summary>
[Parameter] public EventCallback<IReadOnlyList<DiagnosticMarker>> MarkersChanged { get; set; }
[Parameter] public EventCallback<ScriptProblemReport> MarkersChanged { get; set; }
private ElementReference _hostRef;
private DotNetObjectReference<MonacoEditor>? _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<DiagnosticMarker>());
public Task OnMarkersChanged(DiagnosticMarker[]? markers, int total) =>
MarkersChanged.InvokeAsync(ScriptProblemReport.FromEditor(markers, total));
/// <summary>The JS reports, at blur, that the text has grown past (bytes &gt; 0) or back under (0) the sync limit.</summary>
[JSInvokable]
public void OnSyncRefused(int bytes)
{
if (_tooLargeBytes == bytes) return;
_tooLargeBytes = bytes;
StateHasChanged();
}
/// <summary>Programmatic scroll-to-line (called by the problems panel).</summary>
public async Task RevealLineAsync(int line, int column = 1)
@@ -5,12 +5,13 @@
ScriptProblems (unit-tested). *@
@{
var ordered = ScriptProblems.Order(Markers);
var report = Report ?? ScriptProblemReport.Empty;
var ordered = ScriptProblems.Order(report.Markers);
}
<div class="script-problems mt-2 small" role="region" aria-label="Problems">
<div class="d-flex align-items-center gap-2 text-muted mb-1">
<span class="fw-semibold">Problems</span>
<span>@ScriptProblems.Summary(ordered)</span>
<span>@ScriptProblems.Summary(report with { Markers = ordered })</span>
</div>
@if (ordered.Count > 0)
{
@@ -29,18 +30,20 @@
</li>
}
</ul>
@if (ordered.Count > MaxRows)
@if (report.Total > Math.Min(ordered.Count, MaxRows))
{
<div class="text-muted mt-1">… and @(ordered.Count - MaxRows) more</div>
@* The editor reports at most ScriptEditorLimits.MaxReportedMarkers problems (one circuit message); the
rest are still squiggled in the editor. *@
<div class="text-muted mt-1">+@(report.Total - Math.Min(ordered.Count, MaxRows)) more — fix the problems above, or scroll the editor to see them all.</div>
}
}
</div>
@code {
private const int MaxRows = 200;
private const int MaxRows = ScriptEditorLimits.MaxReportedMarkers;
/// <summary>The markers the editor last reported (null before the first diagnostics pass).</summary>
[Parameter] public IReadOnlyList<DiagnosticMarker>? Markers { get; set; }
/// <summary>The report the editor last sent (null before the first diagnostics pass).</summary>
[Parameter] public ScriptProblemReport? Report { get; set; }
/// <summary>Raised when the author picks a problem; wire it to <c>MonacoEditor.RevealLineAsync</c>.</summary>
[Parameter] public EventCallback<DiagnosticMarker> OnSelect { get; set; }
@@ -102,7 +102,7 @@
</div>
<MonacoEditor @ref="_scriptEditor" @bind-Value="_scriptSource" Height="300px" EquipmentId="@EquipmentId"
MarkersChanged="OnScriptMarkersChanged" />
<ScriptProblemsList Markers="_scriptMarkers" OnSelect="RevealScriptProblemAsync" />
<ScriptProblemsList Report="_scriptMarkers" OnSelect="RevealScriptProblemAsync" />
@if (!string.IsNullOrWhiteSpace(_scriptError))
{
<div class="text-danger small mt-2">@_scriptError</div>
@@ -221,9 +221,9 @@
private bool _scriptSaved;
private string? _scriptError;
private MonacoEditor? _scriptEditor;
private IReadOnlyList<DiagnosticMarker> _scriptMarkers = Array.Empty<DiagnosticMarker>();
private ScriptProblemReport _scriptMarkers = ScriptProblemReport.Empty;
private void OnScriptMarkersChanged(IReadOnlyList<DiagnosticMarker> 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<DiagnosticMarker>();
_scriptMarkers = ScriptProblemReport.Empty;
}
_scriptExpanded = !_scriptExpanded;
if (_scriptExpanded)
@@ -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<DiagnosticMarker> Bound(IEnumerable<DiagnosticMarker> 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 " +
@@ -16,4 +16,26 @@ public static class ScriptEditorLimits
/// <summary>The largest serialized editor value the JS will send, leaving 64 KB of frame headroom.</summary>
public const int MaxEditorSyncBytes = (int)HubMaximumReceiveMessageSize - 64 * 1024;
/// <summary>
/// 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 <see cref="MaxDiagnosticMarkers"/>).
/// </summary>
public const int MaxReportedMarkers = 200;
/// <summary>Each reported marker's message is truncated to this many characters before it crosses the circuit.</summary>
public const int MaxReportedMessageChars = 300;
/// <summary>
/// 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.
/// </summary>
public const int MaxDiagnosticMarkers = 1000;
/// <summary>Each diagnostics-endpoint marker message is truncated to this many characters (a Roslyn message
/// can quote an arbitrarily long pasted token).</summary>
public const int MaxDiagnosticMessageChars = 1000;
}
@@ -50,5 +50,54 @@ public static class ScriptProblems
return string.Join(", ", parts);
}
/// <summary>A one-line count for a (possibly capped) report, e.g. <c>"200 errors (showing 200 of 4321)"</c>.</summary>
/// <param name="report">The report the editor sent.</param>
/// <returns>The summary of the shown markers, with the shown/total suffix when some were left out.</returns>
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;
}
/// <summary>Cuts <paramref name="text"/> to at most <paramref name="maxChars"/> characters, ending in an ellipsis.</summary>
/// <param name="text">The text.</param>
/// <param name="maxChars">The maximum length, ellipsis included (at least 1).</param>
/// <returns>The text, unchanged when it already fits.</returns>
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";
}
/// <summary>
/// What the editor reported for the Problems list: at most <see cref="ScriptEditorLimits.MaxReportedMarkers"/>
/// markers in display order (errors first) plus the <see cref="Total"/> 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
/// <see cref="FromEditor"/> clamps it again on arrival.
/// </summary>
/// <param name="Markers">The markers shown.</param>
/// <param name="Total">How many markers the diagnostics pass produced (≥ <c>Markers.Count</c>).</param>
public sealed record ScriptProblemReport(IReadOnlyList<DiagnosticMarker> Markers, int Total)
{
/// <summary>No problems.</summary>
public static readonly ScriptProblemReport Empty = new(Array.Empty<DiagnosticMarker>(), 0);
/// <summary>How many markers were left out of <see cref="Markers"/>.</summary>
public int Omitted => Math.Max(0, Total - Markers.Count);
/// <summary>Builds a report from what the editor JS sent, re-applying the circuit caps defensively.</summary>
/// <param name="markers">The markers sent (already capped by the JS).</param>
/// <param name="total">The total the JS counted.</param>
/// <returns>The clamped report.</returns>
public static ScriptProblemReport FromEditor(IReadOnlyList<DiagnosticMarker>? markers, int total)
{
if (markers is null || markers.Count == 0) return total <= 0 ? Empty : new(Array.Empty<DiagnosticMarker>(), 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));
}
}
@@ -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++;
@@ -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")]