@skyhook-io/radar-app 1.13.3 → 1.13.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,7 @@ import type { DiagnosisResourceRef } from "./diagnoseEvidenceTypes";
25
25
  import type { Diagnosis } from "../../api/diagnose";
26
26
  import { AssessmentSources, ResultCard } from "./parts";
27
27
  import { investigationEvidenceCoverageLimited } from "./investigationState";
28
+ import { groupEvidenceCoverage } from "./investigationEvidencePresentation";
28
29
 
29
30
  const onViewSource = vi.fn();
30
31
  const target = {
@@ -2168,3 +2169,667 @@ describe("InvestigationEvidencePane honest result states", () => {
2168
2169
  expect(html).toContain("CrashLoopBackOff");
2169
2170
  });
2170
2171
  });
2172
+
2173
+ describe("Track A evidence bodies and deep links", () => {
2174
+ const changeArgs = JSON.stringify({
2175
+ kind: "Deployment",
2176
+ namespace: "shop",
2177
+ name: "api",
2178
+ });
2179
+ const change = {
2180
+ kind: "Deployment",
2181
+ apiVersion: "apps/v1",
2182
+ namespace: "shop",
2183
+ name: "api",
2184
+ changeType: "update",
2185
+ timestamp: "2026-09-02T09:00:00Z",
2186
+ };
2187
+
2188
+ function renderWithNav(
2189
+ projection: InvestigationEvidenceProjection,
2190
+ nav: {
2191
+ onOpenResource?: (ref: DiagnosisResourceRef) => void;
2192
+ onOpenTimeline?: (scope: { namespace?: string; name: string }) => void;
2193
+ },
2194
+ ): string {
2195
+ return renderToStaticMarkup(
2196
+ <InvestigationEvidencePane
2197
+ projection={projection}
2198
+ collecting={false}
2199
+ animateGroupIds={new Set()}
2200
+ onViewSource={onViewSource}
2201
+ onViewActivity={() => {}}
2202
+ onOpenResource={nav.onOpenResource}
2203
+ onOpenTimeline={nav.onOpenTimeline}
2204
+ />,
2205
+ );
2206
+ }
2207
+
2208
+ it("renders each workload-logs stream as its own card from one source", () => {
2209
+ const projection = project(
2210
+ tool(
2211
+ "wl",
2212
+ "get_workload_logs",
2213
+ {
2214
+ workload: "deployments/shop/api",
2215
+ pods: 1,
2216
+ logs: [
2217
+ {
2218
+ pod: "api-68c7b766dc-fmphn",
2219
+ container: "api",
2220
+ logs: {
2221
+ lines: [
2222
+ "2026-09-07T08:00:21Z MongoServerError: Authentication failed.",
2223
+ ],
2224
+ totalLines: 50,
2225
+ matchedLines: 1,
2226
+ fallback: false,
2227
+ },
2228
+ },
2229
+ {
2230
+ pod: "api-68c7b766dc-fmphn",
2231
+ container: "istio-proxy",
2232
+ logs: {
2233
+ lines: ["2026-09-07T08:00:19Z info Envoy proxy is ready"],
2234
+ totalLines: 12,
2235
+ matchedLines: 0,
2236
+ fallback: true,
2237
+ },
2238
+ },
2239
+ ],
2240
+ },
2241
+ {
2242
+ summary: JSON.stringify({
2243
+ namespace: "shop",
2244
+ name: "api",
2245
+ kind: "deployment",
2246
+ }),
2247
+ },
2248
+ ),
2249
+ );
2250
+ const partition = partitionInvestigationEvidence(projection.groups);
2251
+ expect(partition.main.map((group) => group.latest.title)).toEqual([
2252
+ "Current logs · api-68c7b766dc-fmphn / api",
2253
+ ]);
2254
+ expect(partition.workload.map((group) => group.latest.title)).toEqual([
2255
+ "Current logs · api-68c7b766dc-fmphn / istio-proxy",
2256
+ ]);
2257
+ const html = render(projection, false, undefined, undefined, () => {});
2258
+ expect(html).toContain("MongoServerError: Authentication failed.");
2259
+ expect(html).toContain("Unfiltered log tail");
2260
+ expect(html).toContain(
2261
+ "Open current Pod shop/api-68c7b766dc-fmphn in Radar",
2262
+ );
2263
+ expect(
2264
+ html.match(
2265
+ new RegExp(investigationEvidenceSourceDomId("turn-0-step-wl"), "g"),
2266
+ ),
2267
+ ).toHaveLength(1);
2268
+ });
2269
+
2270
+ it("renders a firing alert rule with its target instance first and the rule text", () => {
2271
+ const projection = project(
2272
+ tool("diagnose", "diagnose", {
2273
+ resource: {
2274
+ apiVersion: "apps/v1",
2275
+ kind: "Deployment",
2276
+ metadata: { namespace: "shop", name: "api" },
2277
+ },
2278
+ resourceContext: { tier: "basic" },
2279
+ logsCurrent: [
2280
+ {
2281
+ pod: "api-68c7b766dc-fmphn",
2282
+ container: "api",
2283
+ logs: {
2284
+ lines: ["ready"],
2285
+ totalLines: 1,
2286
+ matchedLines: 0,
2287
+ fallback: true,
2288
+ },
2289
+ },
2290
+ ],
2291
+ }),
2292
+ tool(
2293
+ "rules",
2294
+ "get_prometheus_rules",
2295
+ {
2296
+ count: 1,
2297
+ rules: [
2298
+ {
2299
+ group: "kubernetes-apps",
2300
+ type: "alerting",
2301
+ name: "KubePodCrashLooping",
2302
+ query:
2303
+ 'max_over_time(kube_pod_container_status_waiting_reason{reason="CrashLoopBackOff"}[5m]) >= 1',
2304
+ state: "firing",
2305
+ health: "ok",
2306
+ labels: { severity: "warning" },
2307
+ annotations: {
2308
+ summary: "Pod is crash looping.",
2309
+ description:
2310
+ "Pod {{ $labels.namespace }}/{{ $labels.pod }} is in waiting state (reason: CrashLoopBackOff).",
2311
+ },
2312
+ alerts: [
2313
+ {
2314
+ state: "firing",
2315
+ activeAt: "2026-09-07T07:58:00Z",
2316
+ value: "1e+00",
2317
+ labels: { namespace: "monitoring", pod: "other-abc" },
2318
+ },
2319
+ {
2320
+ state: "firing",
2321
+ activeAt: "2026-09-07T07:58:00Z",
2322
+ value: "1e+00",
2323
+ labels: {
2324
+ namespace: "shop",
2325
+ pod: "api-68c7b766dc-fmphn",
2326
+ container: "api",
2327
+ },
2328
+ },
2329
+ ],
2330
+ },
2331
+ ],
2332
+ },
2333
+ { summary: JSON.stringify({ state: "firing" }) },
2334
+ ),
2335
+ );
2336
+ const partition = partitionInvestigationEvidence(projection.groups);
2337
+ expect(
2338
+ partition.main
2339
+ .map((group) => group.latest.title)
2340
+ .filter((title) => title.startsWith("KubePodCrashLooping")),
2341
+ ).toEqual(["KubePodCrashLooping firing"]);
2342
+ const html = render(projection);
2343
+ expect(html).toContain("2 active instances, 1 naming Deployment shop/api");
2344
+ expect(html).toContain("names this resource");
2345
+ expect(html.indexOf("api-68c7b766dc-fmphn")).toBeLessThan(
2346
+ html.indexOf("other-abc"),
2347
+ );
2348
+ expect(html).toContain("Pod is crash looping.");
2349
+ expect(html).toContain("Rule expression");
2350
+ expect(html).toContain("kube_pod_container_status_waiting_reason");
2351
+ expect(html).toContain("a firing rule alone does not establish the cause");
2352
+ expect(html).not.toContain("Open current");
2353
+ });
2354
+
2355
+ it("renders a Helm release with its operation, health issue, and managed resources", () => {
2356
+ const onOpenResource = vi.fn();
2357
+ const projection = project(
2358
+ tool(
2359
+ "helm",
2360
+ "get_helm_release",
2361
+ {
2362
+ name: "shop",
2363
+ namespace: "shop",
2364
+ chart: "shop",
2365
+ chartVersion: "1.4.2",
2366
+ appVersion: "2.0.0",
2367
+ status: "pending-upgrade",
2368
+ revision: 8,
2369
+ updated: "2026-09-07T07:00:00Z",
2370
+ description: "Preparing upgrade",
2371
+ healthIssue: "Deployment shop/api has 0/1 ready replicas",
2372
+ lastOperation: {
2373
+ kind: "pending",
2374
+ status: "stuck_pending",
2375
+ source: "helm_status",
2376
+ confidence: "high",
2377
+ message: "Revision 8 has been pending-upgrade for 42m",
2378
+ },
2379
+ resources: [
2380
+ {
2381
+ kind: "Deployment",
2382
+ apiVersion: "apps/v1",
2383
+ name: "api",
2384
+ namespace: "shop",
2385
+ status: "Running",
2386
+ ready: "0/1",
2387
+ issue: "0/1 ready",
2388
+ },
2389
+ {
2390
+ kind: "Job",
2391
+ apiVersion: "batch/v1",
2392
+ name: "shop-migrate",
2393
+ namespace: "shop",
2394
+ status: "Complete",
2395
+ summary: "1/1 succeeded",
2396
+ },
2397
+ ],
2398
+ },
2399
+ { summary: JSON.stringify({ namespace: "shop", name: "shop" }) },
2400
+ ),
2401
+ );
2402
+ const partition = partitionInvestigationEvidence(projection.groups);
2403
+ expect(partition.main.map((group) => group.latest.title)).toEqual([
2404
+ "Helm release shop/shop",
2405
+ ]);
2406
+ const html = render(
2407
+ projection,
2408
+ false,
2409
+ undefined,
2410
+ undefined,
2411
+ onOpenResource,
2412
+ );
2413
+ expect(html).toContain("pending-upgrade");
2414
+ expect(html).toContain("shop 1.4.2");
2415
+ expect(html).toContain("revision 8");
2416
+ expect(html).toContain("Last operation");
2417
+ expect(html).toContain("stuck pending");
2418
+ expect(html).toContain("Revision 8 has been pending-upgrade for 42m");
2419
+ expect(html).toContain("Deployment shop/api has 0/1 ready replicas");
2420
+ expect(html).toContain("0/1 ready");
2421
+ expect(html).toContain("1/1 succeeded");
2422
+ expect(html).toContain("Open current HelmRelease shop/shop in Radar");
2423
+ });
2424
+
2425
+ it("renders an access check verdict and a subject's bindings", () => {
2426
+ const projection = project(
2427
+ tool("diagnose", "diagnose", {
2428
+ resource: {
2429
+ apiVersion: "apps/v1",
2430
+ kind: "Deployment",
2431
+ metadata: { namespace: "shop", name: "api" },
2432
+ spec: { template: { spec: { serviceAccountName: "api-sa" } } },
2433
+ },
2434
+ resourceContext: { tier: "basic" },
2435
+ }),
2436
+ tool(
2437
+ "check",
2438
+ "get_subject_permissions",
2439
+ {
2440
+ subject: {
2441
+ kind: "ServiceAccount",
2442
+ namespace: "shop",
2443
+ name: "api-sa",
2444
+ },
2445
+ accessCheck: {
2446
+ verb: "get",
2447
+ resource: "secrets",
2448
+ namespace: "shop",
2449
+ resourceName: "db-credentials",
2450
+ allowed: false,
2451
+ reason: "",
2452
+ },
2453
+ },
2454
+ {
2455
+ summary: JSON.stringify({
2456
+ kind: "ServiceAccount",
2457
+ namespace: "shop",
2458
+ name: "api-sa",
2459
+ verb: "get",
2460
+ resource: "secrets",
2461
+ }),
2462
+ },
2463
+ ),
2464
+ tool(
2465
+ "subject",
2466
+ "get_subject_permissions",
2467
+ {
2468
+ subject: {
2469
+ kind: "ServiceAccount",
2470
+ namespace: "shop",
2471
+ name: "api-sa",
2472
+ },
2473
+ bindings: [
2474
+ {
2475
+ bindingKind: "RoleBinding",
2476
+ bindingNamespace: "shop",
2477
+ bindingName: "api-reader",
2478
+ roleKind: "Role",
2479
+ roleNamespace: "shop",
2480
+ roleName: "reader",
2481
+ rulesCount: 3,
2482
+ },
2483
+ {
2484
+ bindingKind: "ClusterRoleBinding",
2485
+ bindingName: "system:basic-user",
2486
+ roleKind: "ClusterRole",
2487
+ roleName: "system:basic-user",
2488
+ rulesCount: 1,
2489
+ inheritedFromGroup: "system:authenticated",
2490
+ },
2491
+ ],
2492
+ flatRules: [{ verbs: ["get"], resources: ["pods"] }],
2493
+ usedByPods: ["shop/api-68c7b766dc-fmphn"],
2494
+ },
2495
+ {
2496
+ summary: JSON.stringify({
2497
+ kind: "ServiceAccount",
2498
+ namespace: "shop",
2499
+ name: "api-sa",
2500
+ }),
2501
+ },
2502
+ ),
2503
+ );
2504
+ const partition = partitionInvestigationEvidence(projection.groups);
2505
+ expect(
2506
+ partition.main
2507
+ .map((group) => group.latest.title)
2508
+ .filter((title) => title.includes("api-sa")),
2509
+ ).toEqual(["Service Account shop/api-sa cannot get secrets"]);
2510
+ expect(
2511
+ partition.workload
2512
+ .map((group) => group.latest.title)
2513
+ .filter((title) => title.includes("api-sa")),
2514
+ ).toEqual(["Permissions of Service Account shop/api-sa"]);
2515
+ const html = render(projection, false, undefined, undefined, () => {});
2516
+ expect(html).toContain("not allowed");
2517
+ expect(html).toContain("db-credentials");
2518
+ expect(html).toContain("No RBAC rule allows it · namespace shop");
2519
+ expect(html).toContain("api-reader");
2520
+ expect(html).toContain("system:basic-user");
2521
+ expect(html).toContain("via system:authenticated");
2522
+ expect(html).toContain("3 rules");
2523
+ expect(html).toContain("1 effective rules");
2524
+ expect(html).toContain("shop/api-68c7b766dc-fmphn");
2525
+ expect(html).toContain("Open current Service Account shop/api-sa in Radar");
2526
+ expect(html).toContain("not what the workload has exercised");
2527
+ });
2528
+
2529
+ it("offers a Timeline link only for changes cards whose producer named a resource", () => {
2530
+ const onOpenTimeline = vi.fn();
2531
+ const projection = project(
2532
+ tool(
2533
+ "changes",
2534
+ "get_changes",
2535
+ { changes: [change] },
2536
+ { summary: changeArgs },
2537
+ ),
2538
+ tool(
2539
+ "changes-ns",
2540
+ "get_changes",
2541
+ { changes: [change] },
2542
+ { summary: JSON.stringify({ namespace: "shop" }) },
2543
+ ),
2544
+ );
2545
+ const withLink = renderWithNav(projection, { onOpenTimeline });
2546
+ expect(withLink.match(/Open the Timeline for shop\/api/g)).toHaveLength(1);
2547
+ expect(withLink).not.toContain("Open the Timeline for shop<");
2548
+ const withoutLink = renderWithNav(projection, {});
2549
+ expect(withoutLink).not.toContain("Open the Timeline");
2550
+ });
2551
+
2552
+ it("opens the relationship root's resource page from a relationships card", () => {
2553
+ const projection = project(
2554
+ tool(
2555
+ "neighborhood",
2556
+ "get_neighborhood",
2557
+ {
2558
+ root: {
2559
+ kind: "Deployment",
2560
+ group: "apps",
2561
+ namespace: "shop",
2562
+ name: "api",
2563
+ },
2564
+ subgraph: {
2565
+ nodes: [
2566
+ { id: "deployment/shop/api", kind: "Deployment", name: "api" },
2567
+ ],
2568
+ edges: [],
2569
+ },
2570
+ truncated: false,
2571
+ },
2572
+ {
2573
+ summary: JSON.stringify({
2574
+ kind: "Deployment",
2575
+ namespace: "shop",
2576
+ name: "api",
2577
+ }),
2578
+ },
2579
+ ),
2580
+ );
2581
+ const html = render(projection, false, undefined, undefined, () => {});
2582
+ expect(html).toContain("Relationships around Deployment api");
2583
+ expect(html).toContain("Open current Deployment shop/api in Radar");
2584
+ });
2585
+
2586
+ it("leads collapsed event cards with the event, keeping counts secondary", () => {
2587
+ const projection = project(
2588
+ tool(
2589
+ "events",
2590
+ "get_events",
2591
+ {
2592
+ events: [
2593
+ {
2594
+ reason: "BackOff",
2595
+ message: "Back-off restarting failed container",
2596
+ type: "Warning",
2597
+ count: 4,
2598
+ lastTimestamp: "2026-09-02T10:00:00Z",
2599
+ },
2600
+ ],
2601
+ },
2602
+ { summary: changeArgs },
2603
+ ),
2604
+ );
2605
+ const html = render(projection);
2606
+ const title = html.indexOf("Kubernetes events");
2607
+ const lead = html.indexOf("BackOff: Back-off restarting failed container");
2608
+ expect(lead).toBeGreaterThan(title);
2609
+ expect(html).not.toContain("1 event group");
2610
+ expect(html).toMatch(/BackOff<span[^>]*>×4<\/span>/);
2611
+ });
2612
+ });
2613
+
2614
+ describe("cited broader cards and coverage rows", () => {
2615
+ const helmPayload = {
2616
+ name: "prometheus",
2617
+ namespace: "opencost",
2618
+ chart: "prometheus",
2619
+ chartVersion: "25.8.0",
2620
+ status: "failed",
2621
+ revision: 1,
2622
+ updated: "2026-09-07T07:00:00Z",
2623
+ healthIssue: "6/12 pods Unschedulable",
2624
+ resources: [],
2625
+ };
2626
+ const permissionsPayload = {
2627
+ subject: { kind: "ServiceAccount", namespace: "other", name: "worker" },
2628
+ accessCheck: {
2629
+ verb: "get",
2630
+ resource: "secrets",
2631
+ namespace: "other",
2632
+ allowed: true,
2633
+ reason: 'RBAC: allowed by ClusterRoleBinding "worker"',
2634
+ },
2635
+ };
2636
+ const rulesPayload = {
2637
+ count: 1,
2638
+ rules: [
2639
+ {
2640
+ group: "general",
2641
+ type: "alerting",
2642
+ name: "TargetDown",
2643
+ query: "up == 0",
2644
+ state: "firing",
2645
+ health: "ok",
2646
+ labels: { severity: "warning" },
2647
+ alerts: [
2648
+ {
2649
+ state: "firing",
2650
+ labels: { namespace: "monitoring", job: "node-exporter" },
2651
+ },
2652
+ ],
2653
+ },
2654
+ ],
2655
+ };
2656
+
2657
+ it("admits a cited helm, permissions or alerts card and counts the rest as withheld", () => {
2658
+ const helmRef = evidenceRef("a", "b");
2659
+ const permRef = evidenceRef("a", "c");
2660
+ const rulesRef = evidenceRef("a", "d");
2661
+ const projection = project(
2662
+ tool("helm", "get_helm_release", helmPayload, {
2663
+ summary: JSON.stringify({ namespace: "opencost", name: "prometheus" }),
2664
+ evidenceRef: helmRef,
2665
+ }),
2666
+ tool("perm", "get_subject_permissions", permissionsPayload, {
2667
+ summary: JSON.stringify({
2668
+ kind: "ServiceAccount",
2669
+ namespace: "other",
2670
+ name: "worker",
2671
+ }),
2672
+ evidenceRef: permRef,
2673
+ }),
2674
+ tool("rules", "get_prometheus_rules", rulesPayload, {
2675
+ summary: JSON.stringify({}),
2676
+ evidenceRef: rulesRef,
2677
+ }),
2678
+ tool("cm", "get_resource", {
2679
+ apiVersion: "v1",
2680
+ kind: "ConfigMap",
2681
+ metadata: { namespace: "shop", name: "vars" },
2682
+ }),
2683
+ );
2684
+ const uncited = partitionInvestigationEvidence(projection.groups);
2685
+ expect(uncited.main).toHaveLength(0);
2686
+ expect(uncited.workload).toHaveLength(0);
2687
+ expect(uncited.hiddenBroader).toBe(4);
2688
+ expect(render(projection)).toContain(
2689
+ "4 results about other resources are not shown; they appear here when the assessment cites them.",
2690
+ );
2691
+
2692
+ const resolution = resolveInvestigationRootCauseEvidence(
2693
+ projection,
2694
+ { status: "linked", refs: [helmRef, permRef, rulesRef] },
2695
+ 0,
2696
+ );
2697
+ const cited = partitionInvestigationEvidence(projection.groups, resolution);
2698
+ expect(cited.main.map((group) => group.latest.title).sort()).toEqual([
2699
+ "Helm release opencost/prometheus",
2700
+ "Service Account other/worker can get secrets",
2701
+ "TargetDown firing",
2702
+ ]);
2703
+ expect(cited.hiddenBroader).toBe(1);
2704
+ const html = render(projection, false, undefined, resolution);
2705
+ expect(html).toContain("6/12 pods Unschedulable");
2706
+ expect(html).toContain(
2707
+ "1 result about another resource is not shown; it appears here when the assessment cites it.",
2708
+ );
2709
+ });
2710
+
2711
+ it("files a complete, empty events query as a checked receipt, not a coverage gap", () => {
2712
+ const projection = project(
2713
+ tool(
2714
+ "events-empty",
2715
+ "get_events",
2716
+ { events: null },
2717
+ {
2718
+ summary: JSON.stringify({
2719
+ kind: "Pod",
2720
+ namespace: "shop",
2721
+ name: "api-a",
2722
+ }),
2723
+ },
2724
+ ),
2725
+ );
2726
+ expect(projection.limitations).toHaveLength(0);
2727
+ const receipts = projection.groups.filter(
2728
+ (group) => group.latest.data.type === "receipt",
2729
+ );
2730
+ expect(receipts.map((group) => group.latest.title)).toEqual([
2731
+ "No events matched",
2732
+ ]);
2733
+ expect(receipts[0].latest.data).toMatchObject({
2734
+ type: "receipt",
2735
+ checked: "events",
2736
+ });
2737
+ const html = render(projection);
2738
+ expect(html).not.toContain("Evidence coverage is incomplete");
2739
+ expect(html).not.toContain("does not establish that no events occurred");
2740
+ });
2741
+
2742
+ it("renders a single-limitation coverage group once, with its source link", () => {
2743
+ const narrowed = {
2744
+ events: null,
2745
+ narrowHint: "Pass a name to narrow the query.",
2746
+ };
2747
+ const projection = project(
2748
+ tool("events-a", "get_events", narrowed, {
2749
+ summary: JSON.stringify({
2750
+ kind: "Pod",
2751
+ namespace: "shop",
2752
+ name: "api-a",
2753
+ }),
2754
+ }),
2755
+ tool("events-b", "get_events", narrowed, {
2756
+ summary: JSON.stringify({
2757
+ kind: "Pod",
2758
+ namespace: "shop",
2759
+ name: "api-b",
2760
+ }),
2761
+ }),
2762
+ tool("events-c", "get_events", narrowed, {
2763
+ summary: JSON.stringify({
2764
+ kind: "Pod",
2765
+ namespace: "shop",
2766
+ name: "api-c",
2767
+ }),
2768
+ }),
2769
+ );
2770
+ expect(projection.limitations).toHaveLength(1);
2771
+ expect(projection.limitations[0].sources).toHaveLength(3);
2772
+ const html = render(projection);
2773
+ const message =
2774
+ "Kubernetes events was narrowed to keep this investigation bounded. Additional matching evidence may exist.";
2775
+ // Live region, strip summary, the group row's label and its text: never
2776
+ // a second identical detail row beneath it.
2777
+ expect(
2778
+ html.match(
2779
+ new RegExp(message.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"),
2780
+ ),
2781
+ ).toHaveLength(4);
2782
+ expect(html).toContain("View latest in Activity");
2783
+ expect(html).not.toContain('aria-controls=":r');
2784
+
2785
+ const twoRows = project(
2786
+ tool(
2787
+ "changes-err",
2788
+ "get_changes",
2789
+ { changes: [], sourcesErrored: ["timeline: permission denied"] },
2790
+ { summary: JSON.stringify({ namespace: "shop" }) },
2791
+ ),
2792
+ tool(
2793
+ "changes-hint",
2794
+ "get_changes",
2795
+ { changes: [], narrowHint: "narrow with since=" },
2796
+ { summary: JSON.stringify({ namespace: "shop" }) },
2797
+ ),
2798
+ );
2799
+ const grouped = groupEvidenceCoverage(twoRows.limitations);
2800
+ expect(
2801
+ grouped.find((group) => group.label === "Recent changes")?.limitations
2802
+ .length,
2803
+ ).toBeGreaterThan(1);
2804
+ expect(render(twoRows)).toContain('aria-expanded="false"');
2805
+ });
2806
+ });
2807
+
2808
+ describe("grouped startup cards", () => {
2809
+ it("opens to the pod names when several pods share one blocker", () => {
2810
+ const blocker = (name: string) => ({
2811
+ kind: "Pod",
2812
+ name,
2813
+ reason: "Unschedulable",
2814
+ severity: "critical",
2815
+ message: "1 node(s) no free host ports",
2816
+ });
2817
+ const projection = project(
2818
+ tool("diagnose", "diagnose", {
2819
+ resource: {
2820
+ apiVersion: "apps/v1",
2821
+ kind: "Deployment",
2822
+ metadata: { namespace: "shop", name: "api" },
2823
+ },
2824
+ resourceContext: { tier: "basic" },
2825
+ pods: 2,
2826
+ startupBlockers: [blocker("api-abc"), blocker("api-def")],
2827
+ }),
2828
+ );
2829
+ const html = render(projection);
2830
+ expect(html).toContain("2 pods · 1 node(s) no free host ports");
2831
+ expect(html).toContain(">api-abc<");
2832
+ expect(html).toContain(">api-def<");
2833
+ expect(html).toContain('aria-expanded="false"');
2834
+ });
2835
+ });