Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Darling/Darling.Tests/AlertNotebookTableCatalogTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,14 @@ public void ACollectorCostCell_ResolvesToTheFleetPartWithoutACollectorName_AndTh
["get_ag_health"] = "DarlingAgReader.cs",
};

/// <summary>#5904: a read whose payload is built in the shared library rather than in the Mcp folder (the investigation
/// tools' core, shared with Lite): the file in <c>PerformanceMonitor.Common/Investigations</c> that builds it.</summary>
private static readonly Dictionary<string, string> s_commonPayloadFiles = new(StringComparer.Ordinal)
{
["list_investigation_requests"] = "InvestigationToolCore.cs",
["get_investigation_reports"] = "InvestigationToolCore.cs",
};

/// <summary>A key the payload code spells through a shared constant rather than a literal: the constant it uses.</summary>
private static readonly Dictionary<string, string> s_keyConstants = new(StringComparer.Ordinal)
{
Expand Down Expand Up @@ -295,6 +303,11 @@ private static string PayloadSourceFor(string read, Dictionary<string, string> t
source += File.ReadAllText(PathTo(s_serviceDir.Append("Mcp").Append(typeFile).ToArray()));
}

if (s_commonPayloadFiles.TryGetValue(read, out var commonFile))
{
source += File.ReadAllText(PathTo("PerformanceMonitor.Common", "Investigations", commonFile));
}

return source;
}

Expand Down
55 changes: 55 additions & 0 deletions Darling/Darling.Tests/InvestigationToolsLiveTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,61 @@ await ExecAsync(connection,
});
}

/// <summary>#5904: the two reads the Investigations page makes carry the fields it draws, in the MCP order (requests
/// <c>opened_at DESC</c>, journal <c>event_time DESC</c>), and the report read the web serves is the full report and
/// brief an operator sees (the same answer the tool gives at the full level).</summary>
[Fact]
public async Task TheWebReads_CarryTheFieldsThePageDraws_InTheMcpOrder_AndTheReportIsServedInFull()
{
await RunAsync(async (store, dataSource, connection, ct) =>
{
var older = (await store.OpenOrJoinAsync(Alert("resources"), ct)).RequestId!.Value;
_now = _now.AddSeconds(2);
var newer = (await store.OpenOrJoinAsync(Alert(), ct)).RequestId!.Value;
var composer = new InvestigationBriefComposer(new PgInvestigationBriefSourceReader(dataSource), new McpDataDetailSetting(McpDataDetail.Full), DarlingInvestigationBriefTools.Tools);
var id = newer.ToString();
var claim = Root(await DarlingMcpInvestigationTools.ClaimAsync(store, composer, id, "agent-a", null, null, ct));
await DarlingMcpInvestigationTools.SubmitAsync(store, id, claim.GetProperty("claim_id").GetString()!, 1,
"needs_person", "could not tell", Evidence, "see the table SecretOrders for the cause", null, null, ct);

async Task<JsonElement> WebRead(string tool, string query)
{
var context = new DefaultHttpContext();
context.Request.QueryString = new QueryString(query);
return Root(await DarlingWebEndpoints.BuildReadDispatch()[tool](context, dataSource, null!));
}

var list = await WebRead("list_investigation_requests", "?limit=100");
var requests = list.GetProperty("requests").EnumerateArray().ToArray();
Assert.Equal(new[] { newer, older }, requests.Select(r => r.GetProperty("request_id").GetGuid()));
foreach (var key in new[] { "request_id", "opened_at", "state", "server_name", "first_metric", "family", "alert_count",
"claimed_by", "lease_until", "reported_at", "route_id" })
Assert.True(requests[0].TryGetProperty(key, out _), "the list row carries " + key);
Assert.Equal("reported", requests[0].GetProperty("state").GetString());
Assert.Equal("agent-a", requests[0].GetProperty("claimed_by").GetString());
Assert.Equal("open", requests[1].GetProperty("state").GetString());

var full = await WebRead("get_investigation_reports", "?request_id=" + id);
var report = Assert.Single(full.GetProperty("reports").EnumerateArray().ToArray());
foreach (var key in new[] { "agent_id", "submitted_at", "verdict", "root_cause_found", "root_cause", "summary", "evidence" })
Assert.True(report.TryGetProperty(key, out _), "the report carries " + key);
Assert.Equal("see the table SecretOrders for the cause", report.GetProperty("summary").GetString());
Assert.True(full.GetProperty("brief").ValueKind == JsonValueKind.Object);
var journal = full.GetProperty("journal").EnumerateArray().ToArray();
Assert.Contains(journal, j => j.GetProperty("event").GetString() == "claimed");
Assert.Contains(journal, j => j.GetProperty("event").GetString() == "reported");
var times = journal.Select(j => j.GetProperty("event_time").GetDateTime()).ToArray();
Assert.Equal(times.OrderByDescending(t => t), times);
foreach (var key in new[] { "event_time", "event", "actor", "reason", "request_id" })
Assert.True(journal[0].TryGetProperty(key, out _), "the journal row carries " + key);

/* The web read is the tool's full-level answer: an operator sees the whole report and brief. */
var tool = Root(await DarlingMcpInvestigationTools.ReportsAsync(dataSource, McpDataDetail.Full, null, id, 24, ct));
Assert.Equal(tool.GetProperty("reports").GetRawText(), full.GetProperty("reports").GetRawText());
Assert.Equal(tool.GetProperty("brief").GetRawText(), full.GetProperty("brief").GetRawText());
});
}

[Fact]
public async Task Submit_AProposedNote_LandsPending_AndTheReportIsStored()
{
Expand Down
134 changes: 134 additions & 0 deletions Darling/Darling.Tests/InvestigationsPageTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
using System.Text.Json;
using Xunit;
using static Darling.Tests.RepoFile;

namespace Darling.Tests;

/// <summary>
/// The web Investigations page (#5904), run in Node against a recording fetch: what it asks the two reads for, the list in
/// the order the read gave, a picked request's report, stored brief and journal, markup in the data drawn as text, the
/// poll's rebuild keeping the open request, and the empty, error and truncation lines. Behavior only; the spelling of
/// the page's source is not pinned.
/// </summary>
public sealed class InvestigationsPageTests
{
private static JsonElement Run()
{
var psi = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
psi.ArgumentList.Add(PathTo("Darling", "Darling.Tests", "investigations-harness.mjs"));
psi.ArgumentList.Add(PathTo("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js"));
Process proc;
try
{
proc = Process.Start(psi)!;
}
catch (Win32Exception)
{
Assert.Skip("Node is not installed, so the shipped page script cannot be run.");
return default;
}

using (proc)
{
var error = proc.StandardError.ReadToEndAsync();
var output = proc.StandardOutput.ReadToEnd().Trim();
if (!proc.WaitForExit(30000))
{
proc.Kill(entireProcessTree: true);
Assert.Fail("the investigations harness did not finish in 30 s");
}

Assert.True(proc.ExitCode == 0, "the investigations harness failed: " + error.Result);
using var doc = JsonDocument.Parse(output);
return doc.RootElement.Clone();
}
}

private static string[] Strings(JsonElement e) => e.EnumerateArray().Select(x => x.GetString()!).ToArray();

[Fact]
public void TheListReadsTheRequestsInTheOrderTheReadGave_WithTheViewerColumns()
{
var first = Run().GetProperty("first");
var call = Assert.Single(first.GetProperty("listCalls").EnumerateArray());
Assert.Equal("100", call.GetProperty("limit").GetString());
Assert.False(call.TryGetProperty("state", out _));
Assert.Empty(first.GetProperty("reportCalls").EnumerateArray());
Assert.Equal("Opened,State,Server,Metric,Family,Alerts,Agent,Lease until,Reported,Route", string.Join(",", Strings(first.GetProperty("headers"))));
var rows = first.GetProperty("rows").EnumerateArray().Select(Strings).ToArray();
Assert.Equal(new[] { "srv-new", "srv-old" }, rows.Select(r => r[2]));
Assert.Equal(new[] { "reported", "open" }, rows.Select(r => r[1]));
Assert.Equal("agent-a", rows[0][6]);
Assert.Equal(",open,claimed,reported,closed,expired", string.Join(",", Strings(first.GetProperty("stateChoices"))));
}

[Fact]
public void PickingARequest_ReadsThatOneRequest_AndDrawsItsReportBriefAndJournal_AsText()
{
var picked = Run().GetProperty("picked");
var read = Assert.Single(picked.GetProperty("reportCalls").EnumerateArray());
Assert.Equal("11111111-1111-1111-1111-111111111111", read.GetProperty("request_id").GetString());
var text = picked.GetProperty("text").GetString()!;
Assert.Contains("Verdict: needs_person", text);
Assert.Contains("agent-a", text);
Assert.Contains("unreviewed and self-attributed", text);
Assert.Contains("Stored brief (data detail: maskLiterals)", text);
// Markup in the stored data is drawn as its characters, never built into elements.
Assert.Contains("SUMMARY <img src=x onerror=alert(1)>", text);
Assert.Contains("<script>x</script>", text);
Assert.Equal(0, picked.GetProperty("createdImgOrScript").GetInt32());
// The journal keeps the read's order (newest first). The claim time's mapping (the claimed event, not the row) is pinned zone-independently by claimedAt below; the rendered text depends on the runner's zone, so it is not asserted.
var journal = picked.GetProperty("journalRows").EnumerateArray().Select(Strings).ToArray();
Assert.Equal(new[] { "reported", "claimed", "opened" }, journal.Select(r => r[1]));
}

[Fact]
public void TheRebuild_KeepsTheOpenRequest_AndCloseEmptiesThePane()
{
var run = Run();
var rebuilt = run.GetProperty("rebuilt");
Assert.True(rebuilt.GetProperty("hasDetail").GetBoolean());
Assert.Single(rebuilt.GetProperty("reportCalls").EnumerateArray());
Assert.Equal("", run.GetProperty("closed").GetString());
}

[Fact]
public void AStateAndFamilyPick_AreSentToTheRead_AndDropTheOpenRequest()
{
var filtered = Run().GetProperty("filtered");
var calls = filtered.GetProperty("listCalls").EnumerateArray().ToArray();
Assert.Equal("claimed", calls[0].GetProperty("state").GetString());
Assert.Equal("claimed", calls[1].GetProperty("state").GetString());
Assert.Equal("blocking", calls[1].GetProperty("family").GetString());
Assert.Empty(filtered.GetProperty("reportCalls").EnumerateArray());
Assert.Equal("", filtered.GetProperty("pane").GetString());
}

[Fact]
public void ARequestWithNoReport_SaysSo_AndTheEmptyErrorAndTruncationLinesAreDrawn()
{
var run = Run();
var none = run.GetProperty("noReport").GetString()!;
Assert.Contains("No agent has reported on this request.", none);
Assert.Contains("never claimed", none);
Assert.DoesNotContain("unreviewed", none);
Assert.True(run.GetProperty("truncated").GetBoolean());
Assert.True(run.GetProperty("empty").GetBoolean());
Assert.True(run.GetProperty("error").GetBoolean());
Assert.Equal("2026-10-14T10:05:00Z", run.GetProperty("claimedAt").GetString());
Assert.Equal(JsonValueKind.Null, run.GetProperty("claimedAtNever").ValueKind);
Assert.False(run.GetProperty("params").GetProperty("none").TryGetProperty("state", out _));
}
}
Loading
Loading