@oneuptime/common 14.0.2 → 14.0.3

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.
Files changed (166) hide show
  1. package/Models/DatabaseModels/AlertFeed.ts +2 -0
  2. package/Models/DatabaseModels/IncidentAlert.ts +540 -0
  3. package/Models/DatabaseModels/IncidentFeed.ts +2 -0
  4. package/Models/DatabaseModels/Index.ts +2 -0
  5. package/Models/DatabaseModels/Project.ts +60 -0
  6. package/Models/DatabaseModels/RumApplication.ts +52 -1
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.ts +28 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.ts +89 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  10. package/Server/Services/IncidentAlertService.ts +1811 -0
  11. package/Server/Services/IncidentService.ts +210 -10
  12. package/Server/Services/IncidentStateTimelineService.ts +24 -0
  13. package/Server/Services/Index.ts +2 -0
  14. package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +24 -2
  15. package/Tests/App/Dashboard/BulkIncidentLinkActions.test.tsx +869 -0
  16. package/Tests/App/Dashboard/CephMetricTooltips.test.tsx +1777 -0
  17. package/Tests/App/Dashboard/CloudFleetSummary.test.ts +8 -0
  18. package/Tests/App/Dashboard/CloudMetricTooltips.test.tsx +887 -0
  19. package/Tests/App/Dashboard/ContainerHostMetricTooltips.test.tsx +1309 -0
  20. package/Tests/App/Dashboard/DiscoveryScanLivePage.test.tsx +39 -0
  21. package/Tests/App/Dashboard/DockerSwarmClustersListTooltips.test.tsx +414 -0
  22. package/Tests/App/Dashboard/DockerSwarmMetricTooltips.test.ts +796 -0
  23. package/Tests/App/Dashboard/DockerSwarmOverviewTooltips.test.tsx +747 -0
  24. package/Tests/App/Dashboard/ExceptionMonitorEnvironmentFilter.test.tsx +38 -18
  25. package/Tests/App/Dashboard/HostDetailViewTooltips.test.tsx +725 -0
  26. package/Tests/App/Dashboard/HostListColumnTooltips.test.tsx +472 -0
  27. package/Tests/App/Dashboard/HostMetricTooltips.test.ts +709 -0
  28. package/Tests/App/Dashboard/HostOverviewTooltips.test.tsx +698 -0
  29. package/Tests/App/Dashboard/HostTooltipHarness.ts +124 -0
  30. package/Tests/App/Dashboard/HostsListTooltips.test.tsx +395 -0
  31. package/Tests/App/Dashboard/IncidentAlertLinkDialog.test.tsx +691 -0
  32. package/Tests/App/Dashboard/IncidentAlertLinkHelpers.test.ts +458 -0
  33. package/Tests/App/Dashboard/IncidentAlertLinkPages.test.tsx +828 -0
  34. package/Tests/App/Dashboard/IncidentAlertLinkSideMenus.test.tsx +231 -0
  35. package/Tests/App/Dashboard/IncidentCreateFromAlerts.test.tsx +689 -0
  36. package/Tests/App/Dashboard/IoTMetricTooltips.test.tsx +1123 -0
  37. package/Tests/App/Dashboard/KubernetesClusterMetricTooltips.test.ts +837 -0
  38. package/Tests/App/Dashboard/KubernetesClusterOverviewTooltips.test.tsx +699 -0
  39. package/Tests/App/Dashboard/KubernetesContainersTabTooltips.test.tsx +320 -0
  40. package/Tests/App/Dashboard/KubernetesCostTooltips.test.tsx +502 -0
  41. package/Tests/App/Dashboard/KubernetesInsightsTooltips.test.tsx +222 -0
  42. package/Tests/App/Dashboard/KubernetesResourceMetricTooltips.test.ts +561 -0
  43. package/Tests/App/Dashboard/KubernetesResourcePagesTooltips.test.tsx +1168 -0
  44. package/Tests/App/Dashboard/LinkIncidentAlertModal.test.tsx +154 -0
  45. package/Tests/App/Dashboard/MetricDescriptionRules.ts +170 -0
  46. package/Tests/App/Dashboard/MetricDescriptionsCatalog.test.ts +206 -0
  47. package/Tests/App/Dashboard/MetricDescriptionsConsistency.test.ts +275 -0
  48. package/Tests/App/Dashboard/NetworkDeviceTooltips.test.tsx +852 -0
  49. package/Tests/App/Dashboard/NetworkDiagnosticsTooltips.test.tsx +556 -0
  50. package/Tests/App/Dashboard/NetworkMetricTooltips.test.ts +1348 -0
  51. package/Tests/App/Dashboard/NetworkSiteTooltips.test.tsx +679 -0
  52. package/Tests/App/Dashboard/ProxmoxMetricTooltips.test.ts +592 -0
  53. package/Tests/App/Dashboard/ProxmoxOverviewTooltips.test.tsx +1002 -0
  54. package/Tests/App/Dashboard/ProxmoxResourcePageTooltips.test.tsx +923 -0
  55. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +1066 -0
  56. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +1195 -0
  57. package/Tests/App/Dashboard/ServerlessMetricTooltips.test.tsx +474 -0
  58. package/Tests/App/Dashboard/ServiceMetricTooltips.test.tsx +772 -0
  59. package/Tests/App/Dashboard/SessionReplayAuditTable.test.tsx +56 -1
  60. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +1207 -0
  61. package/Tests/App/Dashboard/TelemetryResourceTabTooltips.test.tsx +437 -0
  62. package/Tests/App/Dashboard/VMwareMetricTooltips.test.ts +801 -0
  63. package/Tests/App/Dashboard/VMwareOverviewTooltips.test.tsx +601 -0
  64. package/Tests/App/Dashboard/VMwareResourcePagesTooltips.test.tsx +646 -0
  65. package/Tests/App/Dashboard/VMwareTooltipHarness.ts +145 -0
  66. package/Tests/Models/DatabaseModels/IncidentAlertModel.test.ts +554 -0
  67. package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +117 -0
  68. package/Tests/Server/Infrastructure/Postgres/AddIncidentAlertMigration.test.ts +341 -0
  69. package/Tests/Server/Infrastructure/Postgres/AddSessionReplaySameOriginTracePropagationMigration.test.ts +247 -0
  70. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +903 -0
  71. package/Tests/Server/Services/IncidentAlertRegistration.test.ts +96 -0
  72. package/Tests/Server/Services/IncidentAlertService.test.ts +2225 -0
  73. package/Tests/Server/Services/IncidentAlertStateCascade.test.ts +970 -0
  74. package/Tests/Server/Services/IncidentCreateFromAlerts.test.ts +1284 -0
  75. package/Tests/Server/Utils/SessionReplay/SessionReplayGateCachePolicy.test.ts +103 -0
  76. package/Tests/Types/Rum/WebVitals.test.ts +173 -0
  77. package/Tests/UI/Components/FieldLabelInfoCardTooltip.test.tsx +413 -0
  78. package/Tests/UI/Components/InfoTooltip.test.tsx +242 -0
  79. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +207 -3
  80. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDeleteVerb.test.tsx +367 -0
  81. package/Tests/UI/Components/ModelTable/BaseModelTableCreateButtonTitle.test.tsx +275 -0
  82. package/Tests/UI/Components/TableHeaderTooltip.test.tsx +662 -0
  83. package/Tests/UI/Rum/PrivacySummaryCard.test.tsx +166 -9
  84. package/Tests/UI/Rum/RecordingHealthTooltips.test.tsx +447 -0
  85. package/Tests/UI/Rum/ReplayCorrelationPanel.test.tsx +164 -0
  86. package/Tests/UI/Rum/ReplayFrameCapture.test.ts +6750 -0
  87. package/Tests/UI/Rum/ReplayRail.test.tsx +723 -12
  88. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +86 -0
  89. package/Tests/UI/Rum/ReplayScreenshot.test.ts +2124 -0
  90. package/Tests/UI/Rum/ReplayScreenshotActions.test.tsx +3288 -0
  91. package/Tests/UI/Rum/ReplayStage.test.tsx +62 -0
  92. package/Tests/UI/Rum/ReplayStageOverlays.test.tsx +983 -2
  93. package/Tests/UI/Rum/SessionReplaySettingsPolicyLoading.test.tsx +149 -0
  94. package/Tests/UI/Rum/SessionReplaySetupGuide.test.tsx +107 -6
  95. package/Tests/UI/Rum/SessionReplayUsersTableTooltips.test.tsx +330 -0
  96. package/Tests/UI/Rum/UserFlowTooltips.test.tsx +886 -0
  97. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
  98. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
  99. package/Tests/UI/Utils/PermissionGate.test.ts +76 -0
  100. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +696 -0
  101. package/Tests/Utils/Rum/SessionTraceState.test.ts +239 -0
  102. package/Types/Incident/IncidentAlertLink.ts +38 -0
  103. package/Types/Permission.ts +48 -0
  104. package/Types/Rum/SessionReplay.ts +25 -6
  105. package/Types/Rum/WebVitals.ts +52 -0
  106. package/UI/Components/Detail/FieldLabel.tsx +8 -0
  107. package/UI/Components/InfoCard/InfoCard.tsx +47 -1
  108. package/UI/Components/ModelTable/BaseModelTable.tsx +95 -17
  109. package/UI/Components/ModelTable/Column.ts +5 -0
  110. package/UI/Components/Table/TableHeader.tsx +47 -2
  111. package/UI/Components/Table/Types/Column.ts +7 -0
  112. package/UI/Components/Tooltip/InfoTooltip.tsx +78 -0
  113. package/UI/Utils/PermissionGate.ts +11 -2
  114. package/Utils/Incident/IncidentFromAlerts.ts +516 -0
  115. package/Utils/Rum/SessionTraceState.ts +212 -0
  116. package/build/dist/Models/DatabaseModels/AlertFeed.js +2 -0
  117. package/build/dist/Models/DatabaseModels/AlertFeed.js.map +1 -1
  118. package/build/dist/Models/DatabaseModels/IncidentAlert.js +553 -0
  119. package/build/dist/Models/DatabaseModels/IncidentAlert.js.map +1 -0
  120. package/build/dist/Models/DatabaseModels/IncidentFeed.js +2 -0
  121. package/build/dist/Models/DatabaseModels/IncidentFeed.js.map +1 -1
  122. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  123. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  124. package/build/dist/Models/DatabaseModels/Project.js +62 -0
  125. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  126. package/build/dist/Models/DatabaseModels/RumApplication.js +53 -1
  127. package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js +20 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js.map +1 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js +42 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js.map +1 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  134. package/build/dist/Server/Services/IncidentAlertService.js +1370 -0
  135. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -0
  136. package/build/dist/Server/Services/IncidentService.js +164 -12
  137. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  138. package/build/dist/Server/Services/IncidentStateTimelineService.js +21 -0
  139. package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
  140. package/build/dist/Server/Services/Index.js +2 -0
  141. package/build/dist/Server/Services/Index.js.map +1 -1
  142. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js +8 -0
  143. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
  144. package/build/dist/Types/Incident/IncidentAlertLink.js +34 -0
  145. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -0
  146. package/build/dist/Types/Permission.js +42 -0
  147. package/build/dist/Types/Permission.js.map +1 -1
  148. package/build/dist/Types/Rum/WebVitals.js +25 -0
  149. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  150. package/build/dist/UI/Components/Detail/FieldLabel.js +2 -0
  151. package/build/dist/UI/Components/Detail/FieldLabel.js.map +1 -1
  152. package/build/dist/UI/Components/InfoCard/InfoCard.js +22 -1
  153. package/build/dist/UI/Components/InfoCard/InfoCard.js.map +1 -1
  154. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +66 -14
  155. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  156. package/build/dist/UI/Components/Table/TableHeader.js +18 -2
  157. package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
  158. package/build/dist/UI/Components/Tooltip/InfoTooltip.js +28 -0
  159. package/build/dist/UI/Components/Tooltip/InfoTooltip.js.map +1 -0
  160. package/build/dist/UI/Utils/PermissionGate.js +4 -2
  161. package/build/dist/UI/Utils/PermissionGate.js.map +1 -1
  162. package/build/dist/Utils/Incident/IncidentFromAlerts.js +306 -0
  163. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -0
  164. package/build/dist/Utils/Rum/SessionTraceState.js +154 -0
  165. package/build/dist/Utils/Rum/SessionTraceState.js.map +1 -0
  166. package/package.json +1 -1
@@ -0,0 +1,502 @@
1
+ import "@testing-library/jest-dom";
2
+ import {
3
+ act,
4
+ cleanup,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ } from "@testing-library/react";
9
+ import * as React from "react";
10
+ import {
11
+ afterEach,
12
+ beforeEach,
13
+ describe,
14
+ expect,
15
+ jest,
16
+ test,
17
+ } from "@jest/globals";
18
+
19
+ /*
20
+ * The (i) tooltips on the Kubernetes cost pages, rendered for real: the
21
+ * cluster Costs page, the project-wide Costs page and the Right-Sizing card
22
+ * they share. The cost fetches are stubbed with one row each; the tiles,
23
+ * section titles and the Common Table - whose header draws a column's
24
+ * `headerTooltip` - are the real components. EmbeddedMetricCard is reduced
25
+ * to its title, caption and children (its time picker and explorer link are
26
+ * not what is under test).
27
+ *
28
+ * Tooltips are read through aria-describedby after a hover, so a text is
29
+ * checked against the button it belongs to even where two tables share a
30
+ * column title ("Total", "Efficiency").
31
+ */
32
+
33
+ jest.mock("../../../UI/Utils/Navigation", () => {
34
+ return {
35
+ __esModule: true,
36
+ default: {
37
+ getLastParamAsObjectID: () => {
38
+ const { default: ObjectIDType } = jest.requireActual(
39
+ "../../../Types/ObjectID",
40
+ ) as { default: new (id: string) => unknown };
41
+ return new ObjectIDType("0193c0de-8888-4aaa-8bbb-000000000008");
42
+ },
43
+ navigate: () => {
44
+ return undefined;
45
+ },
46
+ },
47
+ };
48
+ });
49
+
50
+ jest.mock("../../../UI/Utils/Project", () => {
51
+ return {
52
+ __esModule: true,
53
+ default: {
54
+ getCurrentProjectId: () => {
55
+ const { default: ObjectIDType } = jest.requireActual(
56
+ "../../../Types/ObjectID",
57
+ ) as { default: new (id: string) => unknown };
58
+ return new ObjectIDType("10000000-0000-4000-8000-000000000001");
59
+ },
60
+ },
61
+ };
62
+ });
63
+
64
+ jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
65
+ return {
66
+ __esModule: true,
67
+ default: {
68
+ getList: () => {
69
+ return Promise.resolve({ data: [], count: 0 });
70
+ },
71
+ },
72
+ };
73
+ });
74
+
75
+ jest.mock("../../../UI/Utils/API/API", () => {
76
+ return {
77
+ __esModule: true,
78
+ default: {
79
+ getFriendlyMessage: (error: unknown) => {
80
+ return (
81
+ ((error as { message?: unknown } | null)?.message as string) ||
82
+ "Could not load"
83
+ );
84
+ },
85
+ },
86
+ };
87
+ });
88
+
89
+ jest.mock(
90
+ "../../../../App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesCostUtils",
91
+ () => {
92
+ return {
93
+ __esModule: true,
94
+ ...(jest.requireActual(
95
+ "../../../../App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesCostUtils",
96
+ ) as Record<string, unknown>),
97
+ fetchCostTrend: () => {
98
+ return Promise.resolve([
99
+ { x: new Date("2026-09-23T10:00:00Z"), y: 1.5 },
100
+ { x: new Date("2026-09-23T11:00:00Z"), y: 1.75 },
101
+ ]);
102
+ },
103
+ fetchNamespaceBreakdown: () => {
104
+ return Promise.resolve([
105
+ {
106
+ namespace: "shop",
107
+ cpuCost: 40,
108
+ ramCost: 30,
109
+ pvCost: 10,
110
+ otherCost: 5,
111
+ totalCost: 85,
112
+ efficiency: 0.55,
113
+ },
114
+ {
115
+ namespace: "__idle__",
116
+ cpuCost: 10,
117
+ ramCost: 5,
118
+ pvCost: 0,
119
+ otherCost: 0,
120
+ totalCost: 15,
121
+ efficiency: 0,
122
+ },
123
+ ]);
124
+ },
125
+ fetchWorkloadBreakdown: () => {
126
+ return Promise.resolve([
127
+ {
128
+ namespace: "shop",
129
+ controllerKind: "Deployment",
130
+ controllerName: "api",
131
+ totalCost: 85,
132
+ efficiency: 0.55,
133
+ },
134
+ ]);
135
+ },
136
+ fetchClusterBreakdown: () => {
137
+ return Promise.resolve([
138
+ {
139
+ clusterName: "production-us-east-1",
140
+ totalCost: 100,
141
+ workloadCost: 85,
142
+ idleCost: 15,
143
+ efficiency: 0.5,
144
+ },
145
+ ]);
146
+ },
147
+ };
148
+ },
149
+ );
150
+
151
+ jest.mock(
152
+ "../../../../App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesRightSizingUtils",
153
+ () => {
154
+ return {
155
+ __esModule: true,
156
+ fetchRightSizingRecommendations: () => {
157
+ const resource: (
158
+ current: number,
159
+ recommended: number,
160
+ ) => Record<string, unknown> = (
161
+ current: number,
162
+ recommended: number,
163
+ ): Record<string, unknown> => {
164
+ return {
165
+ current,
166
+ recommended,
167
+ observedDemand: recommended / 1.25,
168
+ verdict: "Overprovisioned",
169
+ costDeltaInWindow: -1,
170
+ };
171
+ };
172
+
173
+ return Promise.resolve({
174
+ recommendations: [
175
+ {
176
+ namespace: "shop",
177
+ controllerKind: "Deployment",
178
+ controllerName: "api",
179
+ containerName: "api",
180
+ cpu: resource(1, 0.25),
181
+ memory: resource(1024 * 1024 * 1024, 256 * 1024 * 1024),
182
+ costInWindow: 10,
183
+ estimatedMonthlySavings: 42,
184
+ estimatedMonthlyIncrease: 0,
185
+ sampleCount: 168,
186
+ },
187
+ ],
188
+ summary: {
189
+ totalMonthlySavings: 42,
190
+ totalMonthlyIncrease: 0,
191
+ overprovisionedCount: 1,
192
+ underprovisionedCount: 0,
193
+ noRequestSetCount: 0,
194
+ analyzedCount: 3,
195
+ missingMemoryPeakCount: 0,
196
+ },
197
+ observedHours: 168,
198
+ });
199
+ },
200
+ };
201
+ },
202
+ );
203
+
204
+ jest.mock(
205
+ "../../../../App/FeatureSet/Dashboard/src/Components/Metrics/EmbeddedMetricCard",
206
+ () => {
207
+ return {
208
+ __esModule: true,
209
+ default: (props: {
210
+ title?: React.ReactNode;
211
+ description?: React.ReactNode;
212
+ children?: React.ReactNode;
213
+ }) => {
214
+ return (
215
+ <section data-testid="embedded-metric-card">
216
+ <h2>{props.title}</h2>
217
+ <p>{props.description}</p>
218
+ {props.children}
219
+ </section>
220
+ );
221
+ },
222
+ };
223
+ },
224
+ );
225
+
226
+ jest.mock("../../../UI/Components/Charts/Line/LineChart", () => {
227
+ return {
228
+ __esModule: true,
229
+ default: () => {
230
+ return <div data-testid="line-chart" />;
231
+ },
232
+ };
233
+ });
234
+
235
+ import KubernetesClusterCosts from "../../../../App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Costs";
236
+ import KubernetesCosts from "../../../../App/FeatureSet/Dashboard/src/Pages/Kubernetes/Costs";
237
+ import KubernetesRightSizingCard from "../../../../App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/KubernetesRightSizingCard";
238
+ import {
239
+ KUBERNETES_COST_METRIC_DESCRIPTIONS,
240
+ KUBERNETES_RIGHT_SIZING_METRIC_DESCRIPTIONS,
241
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/MetricDescriptions/KubernetesClusterMetricDescriptions";
242
+ import ObjectID from "../../../Types/ObjectID";
243
+ import PageComponentProps from "../../../../App/FeatureSet/Dashboard/src/Pages/PageComponentProps";
244
+
245
+ const PAGE_PROPS: PageComponentProps = {} as PageComponentProps;
246
+
247
+ const COST: typeof KUBERNETES_COST_METRIC_DESCRIPTIONS =
248
+ KUBERNETES_COST_METRIC_DESCRIPTIONS;
249
+ const RIGHT_SIZING: typeof KUBERNETES_RIGHT_SIZING_METRIC_DESCRIPTIONS =
250
+ KUBERNETES_RIGHT_SIZING_METRIC_DESCRIPTIONS;
251
+
252
+ const ABOUT: RegExp = /^About /;
253
+
254
+ async function explanationOf(button: HTMLElement): Promise<string> {
255
+ fireEvent.mouseEnter(button);
256
+ await act(async () => {
257
+ jest.advanceTimersByTime(250);
258
+ });
259
+
260
+ const describedBy: string | null = button.getAttribute("aria-describedby");
261
+
262
+ expect(describedBy).toBeTruthy();
263
+
264
+ return document.getElementById(describedBy as string)?.textContent || "";
265
+ }
266
+
267
+ function infoButtons(): Array<HTMLElement> {
268
+ return screen.queryAllByRole("button", { name: ABOUT });
269
+ }
270
+
271
+ /*
272
+ * Every (i) in page order must be the one expected, and each must show its
273
+ * own text.
274
+ */
275
+ async function expectTooltipsInOrder(
276
+ expected: Array<[string, string]>,
277
+ ): Promise<void> {
278
+ const buttons: Array<HTMLElement> = infoButtons();
279
+
280
+ expect(
281
+ buttons.map((button: HTMLElement): string => {
282
+ return button.getAttribute("aria-label") || "";
283
+ }),
284
+ ).toEqual(
285
+ expected.map((pair: [string, string]): string => {
286
+ return `About ${pair[0]}`;
287
+ }),
288
+ );
289
+
290
+ for (let index: number = 0; index < expected.length; index++) {
291
+ const [title, text] = expected[index]!;
292
+
293
+ expect({ title, shown: await explanationOf(buttons[index]!) }).toEqual({
294
+ title,
295
+ shown: text,
296
+ });
297
+ }
298
+ }
299
+
300
+ const RIGHT_SIZING_TOOLTIPS: Array<[string, string]> = [
301
+ ["Potential Saving", RIGHT_SIZING.potentialSaving],
302
+ ["Over-provisioned", RIGHT_SIZING.overprovisioned],
303
+ ["Under-provisioned", RIGHT_SIZING.underprovisioned],
304
+ ["Analyzed", RIGHT_SIZING.analyzed],
305
+ ["CPU Request", RIGHT_SIZING.cpuRequest],
306
+ ["Memory Request", RIGHT_SIZING.memoryRequest],
307
+ ["Est. Saving", RIGHT_SIZING.estimatedSaving],
308
+ ];
309
+
310
+ beforeEach(() => {
311
+ jest.useFakeTimers();
312
+ });
313
+
314
+ afterEach(() => {
315
+ cleanup();
316
+ jest.useRealTimers();
317
+ });
318
+
319
+ describe("Right-Sizing card", () => {
320
+ async function renderCard(): Promise<void> {
321
+ render(
322
+ <KubernetesRightSizingCard
323
+ kubernetesClusterId={
324
+ new ObjectID("0193c0de-8888-4aaa-8bbb-000000000008")
325
+ }
326
+ startDate={new Date("2026-09-17T10:00:00Z")}
327
+ endDate={new Date("2026-09-24T10:00:00Z")}
328
+ refreshToggle={0}
329
+ />,
330
+ );
331
+
332
+ // Loaded once the saving tile (and the row's saving) show the stub.
333
+ await screen.findAllByText("$42.00");
334
+ }
335
+
336
+ test("every tile and every metric column carries its own (i)", async () => {
337
+ await renderCard();
338
+ await expectTooltipsInOrder(RIGHT_SIZING_TOOLTIPS);
339
+ });
340
+
341
+ test("the identity columns (container, namespace) carry none", async () => {
342
+ await renderCard();
343
+
344
+ expect(
345
+ screen.queryByRole("button", { name: "About Container" }),
346
+ ).not.toBeInTheDocument();
347
+ expect(
348
+ screen.queryByRole("button", { name: "About Namespace" }),
349
+ ).not.toBeInTheDocument();
350
+ });
351
+
352
+ test("a sortable column's (i) sits beside - not inside - the sort button", async () => {
353
+ await renderCard();
354
+
355
+ const info: HTMLElement = screen.getByRole("button", {
356
+ name: "About Est. Saving",
357
+ });
358
+
359
+ expect(info.parentElement?.closest("button, a")).toBeNull();
360
+ expect(info.closest("th")).toHaveTextContent("Est. Saving");
361
+ });
362
+
363
+ test("the card's caption spells out the percentile instead of a bare P95", async () => {
364
+ await renderCard();
365
+
366
+ expect(
367
+ screen.getByText(
368
+ "Recommended CPU and memory requests per container, derived from observed demand: the 95th percentile of hourly CPU usage (95% of hourly readings were lower) and the peak memory working set, each with 25% headroom.",
369
+ ),
370
+ ).toBeInTheDocument();
371
+ expect(screen.queryByText(new RegExp("\\bP95\\b"))).not.toBeInTheDocument();
372
+ });
373
+ });
374
+
375
+ describe("cluster Costs page", () => {
376
+ async function renderPage(): Promise<void> {
377
+ render(<KubernetesClusterCosts {...PAGE_PROPS} />);
378
+
379
+ await screen.findAllByText("$100.00");
380
+ await screen.findAllByText("$42.00");
381
+ }
382
+
383
+ test("every tile, the spend chart, the right-sizing card and every cost column carry their own (i)", async () => {
384
+ await renderPage();
385
+ await expectTooltipsInOrder([
386
+ ["Spend", COST.spendTrend],
387
+ ["Total Spend", COST.totalSpend],
388
+ ["Workload Spend", COST.workloadSpend],
389
+ ["Idle Spend", COST.idleSpend],
390
+ ["Idle %", COST.idlePercent],
391
+ ...RIGHT_SIZING_TOOLTIPS,
392
+ ["CPU", COST.namespaceCpuCost],
393
+ ["Memory", COST.namespaceMemoryCost],
394
+ ["Storage", COST.namespaceStorageCost],
395
+ ["Other", COST.namespaceOtherCost],
396
+ ["Total", COST.namespaceTotalCost],
397
+ ["Efficiency", COST.efficiency],
398
+ ["Total", COST.workloadTotalCost],
399
+ ["Efficiency", COST.efficiency],
400
+ ]);
401
+ });
402
+
403
+ test("the tiles show the numbers their texts describe", async () => {
404
+ await renderPage();
405
+
406
+ // The tile a title's (i) belongs to.
407
+ const tileOf: (title: string) => HTMLElement = (
408
+ title: string,
409
+ ): HTMLElement => {
410
+ return screen
411
+ .getByRole("button", { name: `About ${title}` })
412
+ .closest(".rounded-xl") as HTMLElement;
413
+ };
414
+
415
+ // Total includes the idle row; workload spend leaves it out.
416
+ expect(tileOf("Total Spend")).toHaveTextContent("$100.00");
417
+ expect(tileOf("Workload Spend")).toHaveTextContent("$85.00");
418
+ expect(tileOf("Idle Spend")).toHaveTextContent("$15.00");
419
+ expect(tileOf("Idle %")).toHaveTextContent("15%");
420
+ });
421
+
422
+ test("the spend section's (i) sits in the section title", async () => {
423
+ await renderPage();
424
+
425
+ const info: HTMLElement = screen.getByRole("button", {
426
+ name: "About Spend",
427
+ });
428
+
429
+ expect(info.closest("h2")).toHaveTextContent("Spend");
430
+ });
431
+
432
+ test("no (i) on the page is nested inside another button or a link", async () => {
433
+ await renderPage();
434
+
435
+ for (const button of infoButtons()) {
436
+ expect(button.parentElement?.closest("button, a")).toBeNull();
437
+ }
438
+ });
439
+ });
440
+
441
+ describe("project Costs page", () => {
442
+ async function renderPage(): Promise<void> {
443
+ render(<KubernetesCosts {...PAGE_PROPS} />);
444
+
445
+ await screen.findByText("across 1 cluster");
446
+ }
447
+
448
+ test("every tile, the spend chart and every cost column carry their own (i)", async () => {
449
+ await renderPage();
450
+ await expectTooltipsInOrder([
451
+ ["Kubernetes Spend", COST.fleetSpendTrend],
452
+ ["Total Spend", COST.fleetTotalSpend],
453
+ ["Workload Spend", COST.fleetWorkloadSpend],
454
+ ["Idle Spend", COST.fleetIdleSpend],
455
+ ["Idle %", COST.fleetIdlePercent],
456
+ ["Workload", COST.clusterWorkloadCost],
457
+ ["Idle", COST.clusterIdleCost],
458
+ ["Total", COST.clusterTotalCost],
459
+ ["Efficiency", COST.clusterEfficiency],
460
+ ]);
461
+ });
462
+
463
+ test("the cluster column is a link, and carries no (i)", async () => {
464
+ await renderPage();
465
+
466
+ expect(
467
+ screen.queryByRole("button", { name: "About Cluster" }),
468
+ ).not.toBeInTheDocument();
469
+ });
470
+
471
+ test("the two costs pages show every cost description between them", () => {
472
+ const shownOnPages: Set<string> = new Set([
473
+ COST.spendTrend,
474
+ COST.totalSpend,
475
+ COST.workloadSpend,
476
+ COST.idleSpend,
477
+ COST.idlePercent,
478
+ COST.namespaceCpuCost,
479
+ COST.namespaceMemoryCost,
480
+ COST.namespaceStorageCost,
481
+ COST.namespaceOtherCost,
482
+ COST.namespaceTotalCost,
483
+ COST.efficiency,
484
+ COST.workloadTotalCost,
485
+ COST.fleetSpendTrend,
486
+ COST.fleetTotalSpend,
487
+ COST.fleetWorkloadSpend,
488
+ COST.fleetIdleSpend,
489
+ COST.fleetIdlePercent,
490
+ COST.clusterWorkloadCost,
491
+ COST.clusterIdleCost,
492
+ COST.clusterTotalCost,
493
+ COST.clusterEfficiency,
494
+ ]);
495
+
496
+ expect(shownOnPages.size).toBe(Object.keys(COST).length);
497
+
498
+ for (const text of Object.values(COST)) {
499
+ expect(shownOnPages.has(text)).toBe(true);
500
+ }
501
+ });
502
+ });