using System.Text; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis; /// /// R04:P2 — an abandoned editor request (Monaco cancelled it, or the browser aborted the fetch) used to run /// its full compile to completion. The request's token now reaches the service, the Roslyn calls and the /// catalog, and the endpoints bind HttpContext.RequestAborted. /// public sealed class AnalysisCancellationTests { internal sealed class CapturingCatalog : IScriptTagCatalog { public readonly List Seen = new(); public Task> GetPathsAsync(string? filter, CancellationToken ct) { Seen.Add(ct); return Task.FromResult>(new[] { "Plant/Dev/Speed" }); } public Task GetTagInfoAsync(string path, CancellationToken ct) { Seen.Add(ct); return Task.FromResult(new ScriptTagInfo(path, "Tag", "Float", "DRV-1")); } public Task> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct) { Seen.Add(ct); return Task.FromResult>(new[] { "Speed" }); } } private static CancellationToken Cancelled() => new(canceled: true); [Fact] public async Task Diagnose_with_a_cancelled_token_throws_instead_of_returning_empty_markers() { var svc = new ScriptAnalysisService(); // Positive control: the same request with a live token produces the error marker. (await svc.DiagnoseAsync(new DiagnoseRequest("return ctx.Nope();"), CancellationToken.None)) .Markers.ShouldContain(m => m.Severity == 8); await Should.ThrowAsync( svc.DiagnoseAsync(new DiagnoseRequest("return ctx.Nope();"), Cancelled())); } [Fact] public async Task Completions_hover_and_signature_help_honour_a_cancelled_token() { var svc = new ScriptAnalysisService(new CapturingCatalog()); const string code = "return ctx.GetTag(\"\");"; // Positive controls. (await svc.CompleteAsync(new CompletionsRequest(code, 1, 20), CancellationToken.None)).Items.ShouldNotBeEmpty(); (await svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), CancellationToken.None)) .Markdown.ShouldNotBeNull(); svc.SignatureHelp(new SignatureHelpRequest("ctx.GetTag(", 1, 12), CancellationToken.None).Label.ShouldNotBeNull(); await Should.ThrowAsync(svc.CompleteAsync(new CompletionsRequest(code, 1, 20), Cancelled())); await Should.ThrowAsync( svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), Cancelled())); Should.Throw(() => svc.SignatureHelp(new SignatureHelpRequest("ctx.GetTag(", 1, 12), Cancelled())); } [Fact] public async Task The_request_token_reaches_the_catalog() { var catalog = new CapturingCatalog(); var svc = new ScriptAnalysisService(catalog); using var cts = new CancellationTokenSource(); await svc.CompleteAsync(new CompletionsRequest("return ctx.GetTag(\"\");", 1, 20), cts.Token); await svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), cts.Token); await svc.DiagnoseAsync(new DiagnoseRequest("return ctx.GetTag(\"{{equip}}/Nope\").Value;", "EQ-1"), cts.Token); catalog.Seen.Count.ShouldBe(3); catalog.Seen.ShouldAllBe(t => t == cts.Token); } // ---------------------------------------------------------------- endpoints // A bare DefaultHttpContext reports "no body"; Kestrel sets this feature for a real POST. private sealed class HasBody : Microsoft.AspNetCore.Http.Features.IHttpRequestBodyDetectionFeature { public bool CanHaveBody => true; } internal static (WebApplication App, CapturingCatalog Catalog) BuildApp() { var builder = WebApplication.CreateBuilder(); var catalog = new CapturingCatalog(); builder.Services.AddSingleton(catalog); builder.Services.AddScoped(sp => new ScriptAnalysisService(sp.GetRequiredService())); builder.Services.AddAuthorization(); var app = builder.Build(); app.MapScriptAnalysisEndpoints(); return (app, catalog); } internal static IReadOnlyList Endpoints(WebApplication app) => ((IEndpointRouteBuilder)app).DataSources.SelectMany(d => d.Endpoints).OfType().ToList(); [Fact] public async Task The_endpoint_binds_RequestAborted_through_to_the_catalog() { var (app, catalog) = BuildApp(); var endpoint = Endpoints(app).Single(e => e.RoutePattern.RawText!.EndsWith("/completions")); using var scope = app.Services.CreateScope(); using var aborted = new CancellationTokenSource(); var ctx = new DefaultHttpContext { RequestServices = scope.ServiceProvider, RequestAborted = aborted.Token }; ctx.Request.Method = "POST"; ctx.Request.ContentType = "application/json"; var body = Encoding.UTF8.GetBytes("""{"codeText":"return ctx.GetTag(\"\");","line":1,"column":20}"""); ctx.Request.Body = new MemoryStream(body); ctx.Request.ContentLength = body.Length; ctx.Response.Body = new MemoryStream(); ctx.Features.Set(new HasBody()); await endpoint.RequestDelegate!(ctx); ctx.Response.StatusCode.ShouldBe(200); catalog.Seen.ShouldHaveSingleItem().ShouldBe(aborted.Token); } }