@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,1195 @@
1
+ import "@testing-library/jest-dom";
2
+ import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ jest,
8
+ test,
9
+ } from "@jest/globals";
10
+ import {
11
+ act,
12
+ cleanup,
13
+ fireEvent,
14
+ render,
15
+ screen,
16
+ within,
17
+ } from "@testing-library/react";
18
+ import * as React from "react";
19
+ import getJestMockFunction, { MockFunction } from "../../MockType";
20
+
21
+ /*
22
+ * The RUM application overview (Pages/Rum/View/Overview.tsx), rendered for
23
+ * real: every tile, every chart card and the web-vitals card, with only the
24
+ * network, the router and the chart canvas replaced.
25
+ *
26
+ * What is pinned here is what a customer actually reads - the value and the
27
+ * small line under it for each of the eight tiles, in the normal case and in
28
+ * each way a lookup can come back empty or fail - and that every tile and
29
+ * chart title carries an (i) whose tooltip is the matching entry of
30
+ * RUM_METRIC_DESCRIPTIONS. The pure wording decisions are unit-tested in
31
+ * App/Tests/Dashboard/RumOverviewMetrics.test.ts; this suite proves the page
32
+ * wires them to the right data.
33
+ */
34
+
35
+ const MODEL_ID: string = "84858d6c-1111-4aaa-8bbb-000000000001";
36
+ const PROJECT_ID: string = "10000000-0000-4000-8000-000000000001";
37
+
38
+ const getItemMock: MockFunction = getJestMockFunction();
39
+ const countMock: MockFunction = getJestMockFunction();
40
+ const fetchSpanMetricsMock: MockFunction = getJestMockFunction();
41
+ const fetchSpanNameStatsMock: MockFunction = getJestMockFunction();
42
+ const fetchWebVitalsMock: MockFunction = getJestMockFunction();
43
+ const fetchSignalsMock: MockFunction = getJestMockFunction();
44
+ const fetchSessionReplayListMock: MockFunction = getJestMockFunction();
45
+ const lineChartMock: MockFunction = getJestMockFunction();
46
+
47
+ /*
48
+ * The arrow wrappers are load bearing: jest.mock is hoisted above the
49
+ * declarations, so the mock functions are only read when a call happens.
50
+ */
51
+ jest.mock("react-i18next", () => {
52
+ return {
53
+ useTranslation: () => {
54
+ return {
55
+ t: (key: string, opts?: { defaultValue?: string }): string => {
56
+ return opts?.defaultValue ?? key;
57
+ },
58
+ };
59
+ },
60
+ };
61
+ });
62
+
63
+ jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
64
+ return {
65
+ __esModule: true,
66
+ default: {
67
+ getItem: (...args: Array<unknown>): unknown => {
68
+ return getItemMock(...args);
69
+ },
70
+ count: (...args: Array<unknown>): unknown => {
71
+ return countMock(...args);
72
+ },
73
+ getCommonHeaders: (): Record<string, string> => {
74
+ return {};
75
+ },
76
+ },
77
+ };
78
+ });
79
+
80
+ jest.mock("../../../UI/Utils/Navigation", () => {
81
+ return {
82
+ __esModule: true,
83
+ default: {
84
+ getLastParamAsObjectID: (): unknown => {
85
+ const { default: ObjectIDType } = jest.requireActual(
86
+ "../../../Types/ObjectID",
87
+ ) as { default: new (id: string) => unknown };
88
+ return new ObjectIDType("84858d6c-1111-4aaa-8bbb-000000000001");
89
+ },
90
+ },
91
+ };
92
+ });
93
+
94
+ jest.mock("../../../UI/Utils/Project", () => {
95
+ return {
96
+ __esModule: true,
97
+ default: {
98
+ getCurrentProjectId: (): unknown => {
99
+ const { default: ObjectIDType } = jest.requireActual(
100
+ "../../../Types/ObjectID",
101
+ ) as { default: new (id: string) => unknown };
102
+ return new ObjectIDType("10000000-0000-4000-8000-000000000001");
103
+ },
104
+ },
105
+ };
106
+ });
107
+
108
+ /*
109
+ * recharts measures a 0x0 parent in jsdom and draws nothing, so each chart
110
+ * card is checked by the series it hands the chart.
111
+ */
112
+ jest.mock("../../../UI/Components/Charts/Line/LineChart", () => {
113
+ return {
114
+ __esModule: true,
115
+ default: (props: { data: Array<{ seriesName: string }> }) => {
116
+ lineChartMock(props);
117
+ return (
118
+ <div data-testid="line-chart">
119
+ {props.data
120
+ .map((s: { seriesName: string }) => {
121
+ return s.seriesName;
122
+ })
123
+ .join(",")}
124
+ </div>
125
+ );
126
+ },
127
+ };
128
+ });
129
+
130
+ jest.mock(
131
+ "../../../UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker",
132
+ () => {
133
+ return {
134
+ __esModule: true,
135
+ default: (props: { onChange: (value: unknown) => void }) => {
136
+ return (
137
+ <button
138
+ type="button"
139
+ onClick={() => {
140
+ props.onChange({ range: "Past 1 Day" });
141
+ }}
142
+ >
143
+ Pick past day
144
+ </button>
145
+ );
146
+ },
147
+ };
148
+ },
149
+ );
150
+
151
+ jest.mock(
152
+ "../../../../App/FeatureSet/Dashboard/src/Components/TelemetryResource/AutoRefreshControl",
153
+ () => {
154
+ return {
155
+ __esModule: true,
156
+ default: (props: {
157
+ onManualRefresh: () => void;
158
+ timeRangePicker?: React.ReactNode;
159
+ }) => {
160
+ return (
161
+ <div>
162
+ {props.timeRangePicker}
163
+ <button type="button" onClick={props.onManualRefresh}>
164
+ Refresh now
165
+ </button>
166
+ </div>
167
+ );
168
+ },
169
+ };
170
+ },
171
+ );
172
+
173
+ // No interval timer: refreshes happen only when a test asks for one.
174
+ jest.mock(
175
+ "../../../../App/FeatureSet/Dashboard/src/Components/TelemetryResource/useAutoRefresh",
176
+ () => {
177
+ return {
178
+ __esModule: true,
179
+ default: () => {
180
+ return {
181
+ autoRefreshInterval: "off",
182
+ setAutoRefreshInterval: () => {},
183
+ };
184
+ },
185
+ };
186
+ },
187
+ );
188
+
189
+ jest.mock(
190
+ "../../../../App/FeatureSet/Dashboard/src/Components/TelemetryResource/telemetryMetrics",
191
+ () => {
192
+ const format: Record<string, unknown> = jest.requireActual(
193
+ "../../../../App/FeatureSet/Dashboard/src/Components/TelemetryResource/telemetryFormat",
194
+ ) as Record<string, unknown>;
195
+ return {
196
+ __esModule: true,
197
+ ...format,
198
+ fetchSpanMetrics: (...args: Array<unknown>): unknown => {
199
+ return fetchSpanMetricsMock(...args);
200
+ },
201
+ fetchSpanNameStats: (...args: Array<unknown>): unknown => {
202
+ return fetchSpanNameStatsMock(...args);
203
+ },
204
+ fetchWebVitals: (...args: Array<unknown>): unknown => {
205
+ return fetchWebVitalsMock(...args);
206
+ },
207
+ fetchLogAndExceptionSignals: (...args: Array<unknown>): unknown => {
208
+ return fetchSignalsMock(...args);
209
+ },
210
+ };
211
+ },
212
+ );
213
+
214
+ jest.mock(
215
+ "../../../../App/FeatureSet/Dashboard/src/Components/SessionReplay/SessionReplayTable",
216
+ () => {
217
+ return {
218
+ __esModule: true,
219
+ fetchSessionReplayList: (...args: Array<unknown>): unknown => {
220
+ return fetchSessionReplayListMock(...args);
221
+ },
222
+ };
223
+ },
224
+ );
225
+
226
+ jest.mock(
227
+ "../../../../App/FeatureSet/Dashboard/src/Components/SessionReplay/useSessionReplayHealth",
228
+ () => {
229
+ return {
230
+ __esModule: true,
231
+ default: () => {
232
+ return {
233
+ isLoading: false,
234
+ error: null,
235
+ diagnosis: {
236
+ state: "healthy",
237
+ severity: "ok",
238
+ title: "Recording healthy",
239
+ detail: "",
240
+ },
241
+ refresh: async () => {},
242
+ };
243
+ },
244
+ };
245
+ },
246
+ );
247
+
248
+ // A plain anchor, so hrefs can be read and nesting checked without a router.
249
+ jest.mock(
250
+ "../../../../App/FeatureSet/Dashboard/src/Components/AppLink/AppLink",
251
+ () => {
252
+ return {
253
+ __esModule: true,
254
+ default: (props: {
255
+ to?: { toString: () => string };
256
+ className?: string;
257
+ children: React.ReactNode;
258
+ }) => {
259
+ return (
260
+ <a
261
+ href={props.to ? props.to.toString() : ""}
262
+ className={props.className}
263
+ >
264
+ {props.children}
265
+ </a>
266
+ );
267
+ },
268
+ };
269
+ },
270
+ );
271
+
272
+ import RumApplicationOverview from "../../../../App/FeatureSet/Dashboard/src/Pages/Rum/View/Overview";
273
+ import {
274
+ RUM_METRIC_DESCRIPTIONS,
275
+ RumMetric,
276
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/MetricDescriptions/RumMetricDescriptions";
277
+ import {
278
+ SpanMetrics,
279
+ SpanNameStats,
280
+ LogAndExceptionSignals,
281
+ WebVital,
282
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/TelemetryResource/telemetryMetrics";
283
+ import PageComponentProps from "../../../../App/FeatureSet/Dashboard/src/Pages/PageComponentProps";
284
+ import {
285
+ WebVitalDefinition,
286
+ WebVitalDefinitions,
287
+ } from "../../../Types/Rum/WebVitals";
288
+ import ObjectID from "../../../Types/ObjectID";
289
+
290
+ interface TimePoint {
291
+ x: Date;
292
+ y: number;
293
+ }
294
+
295
+ const T0: Date = new Date("2026-09-24T10:00:00.000Z");
296
+ const T1: Date = new Date("2026-09-24T10:05:00.000Z");
297
+ const T2: Date = new Date("2026-09-24T10:10:00.000Z");
298
+
299
+ function points(...values: Array<[Date, number]>): Array<TimePoint> {
300
+ return values.map(([x, y]: [Date, number]): TimePoint => {
301
+ return { x, y };
302
+ });
303
+ }
304
+
305
+ const APP: Record<string, unknown> = {
306
+ name: "Checkout Web",
307
+ appIdentifier: "checkout-web",
308
+ clientType: "Browser",
309
+ sdkLanguage: "webjs",
310
+ agentVersion: "1.30.0",
311
+ otelCollectorStatus: "connected",
312
+ lastSeenAt: new Date(),
313
+ };
314
+
315
+ const ALL_SPANS: SpanMetrics = {
316
+ total: 45600,
317
+ errors: 912,
318
+ errorRatePercent: 2,
319
+ p95DurationMs: 123.4,
320
+ countSeries: points([T0, 20000], [T1, 25600]),
321
+ errorSeries: points([T0, 400], [T1, 512]),
322
+ p95Series: points([T0, 110], [T1, 136.8]),
323
+ };
324
+
325
+ const PAGE_LOAD_SERIES: SpanMetrics = {
326
+ total: 1234,
327
+ errors: 3,
328
+ errorRatePercent: (3 / 1234) * 100,
329
+ p95DurationMs: 2300,
330
+ countSeries: points([T0, 600], [T1, 634]),
331
+ errorSeries: points([T1, 3]),
332
+ p95Series: points([T0, 2200], [T1, 2400]),
333
+ };
334
+
335
+ const PAGE_LOAD_STATS: SpanNameStats = {
336
+ count: 1234,
337
+ errorCount: 3,
338
+ avgDurationMs: 1000,
339
+ p50DurationMs: 850,
340
+ p95DurationMs: 2340,
341
+ p99DurationMs: 4100,
342
+ };
343
+
344
+ const SIGNALS: LogAndExceptionSignals = {
345
+ logs: {
346
+ total: 300,
347
+ errorCount: 12,
348
+ countSeries: points([T0, 100], [T1, 200]),
349
+ errorSeries: points([T1, 12]),
350
+ failed: false,
351
+ },
352
+ exceptions: {
353
+ total: 17,
354
+ unhandledCount: 5,
355
+ unhandledSeries: points([T0, 2], [T2, 3]),
356
+ handledSeries: points([T0, 4], [T1, 8]),
357
+ failed: false,
358
+ },
359
+ };
360
+
361
+ const VITALS: Array<WebVital> = WebVitalDefinitions.map(
362
+ (d: WebVitalDefinition): WebVital => {
363
+ return {
364
+ key: d.key,
365
+ label: d.label,
366
+ description: d.description,
367
+ value: d.unit === "score" ? 0.05 : d.thresholds.warn / 2,
368
+ unit: d.unit,
369
+ thresholds: d.thresholds,
370
+ };
371
+ },
372
+ );
373
+
374
+ function sessions(count: number): Array<{ sessionId: string }> {
375
+ return Array.from({ length: count }, (_: unknown, i: number) => {
376
+ return { sessionId: `s-${i}` };
377
+ });
378
+ }
379
+
380
+ const TILE_TITLES: Array<[string, RumMetric]> = [
381
+ ["Page loads", "pageLoads"],
382
+ ["Page load time (p95)", "pageLoadTime"],
383
+ ["Events", "events"],
384
+ ["Error rate", "errorRate"],
385
+ ["Event duration (p95)", "eventDuration"],
386
+ ["Exceptions", "exceptions"],
387
+ ["Clients", "clients"],
388
+ ["Sessions recorded", "sessionsRecorded"],
389
+ ];
390
+
391
+ const CHART_TITLES: Array<[string, RumMetric]> = [
392
+ ["Page loads", "pageLoadsChart"],
393
+ ["Page load time (p95)", "pageLoadTimeChart"],
394
+ ["Events", "eventsChart"],
395
+ ["Event duration (p95)", "eventDurationChart"],
396
+ ["Exceptions", "exceptionsChart"],
397
+ ["Logs", "logsChart"],
398
+ ];
399
+
400
+ function never<T>(): Promise<T> {
401
+ return new Promise<T>(() => {});
402
+ }
403
+
404
+ /*
405
+ * A rejection created while arranging is only handled once the page asks
406
+ * for it; marking it handled up front keeps Node from reporting it first.
407
+ */
408
+ function rejected<T>(message: string): Promise<T> {
409
+ const promise: Promise<T> = Promise.reject(new Error(message));
410
+ promise.catch(() => {});
411
+ return promise;
412
+ }
413
+
414
+ interface Scenario {
415
+ app?: Record<string, unknown> | undefined;
416
+ clientCount?: Promise<number> | undefined;
417
+ allSpans?: Promise<SpanMetrics> | undefined;
418
+ pageLoadSeries?: Promise<SpanMetrics> | undefined;
419
+ pageLoadStats?: Promise<SpanNameStats> | undefined;
420
+ signals?: Promise<LogAndExceptionSignals> | undefined;
421
+ vitals?: Promise<Array<WebVital>> | undefined;
422
+ sessionList?: Promise<unknown> | undefined;
423
+ }
424
+
425
+ function arrange(scenario: Scenario = {}): void {
426
+ getItemMock.mockImplementation(async () => {
427
+ return scenario.app ?? APP;
428
+ });
429
+ countMock.mockImplementation(() => {
430
+ return scenario.clientCount ?? Promise.resolve(4);
431
+ });
432
+ fetchSpanMetricsMock.mockImplementation((scope: unknown) => {
433
+ if ((scope as { spanName?: string }).spanName) {
434
+ return scenario.pageLoadSeries ?? Promise.resolve(PAGE_LOAD_SERIES);
435
+ }
436
+ return scenario.allSpans ?? Promise.resolve(ALL_SPANS);
437
+ });
438
+ fetchSpanNameStatsMock.mockImplementation(() => {
439
+ return scenario.pageLoadStats ?? Promise.resolve(PAGE_LOAD_STATS);
440
+ });
441
+ fetchSignalsMock.mockImplementation(() => {
442
+ return scenario.signals ?? Promise.resolve(SIGNALS);
443
+ });
444
+ fetchWebVitalsMock.mockImplementation(() => {
445
+ return scenario.vitals ?? Promise.resolve(VITALS);
446
+ });
447
+ fetchSessionReplayListMock.mockImplementation(() => {
448
+ return (
449
+ scenario.sessionList ??
450
+ Promise.resolve({
451
+ sessions: sessions(50),
452
+ nextCursor: { id: "next" },
453
+ ignoredFilters: [],
454
+ })
455
+ );
456
+ });
457
+ }
458
+
459
+ async function flush(): Promise<void> {
460
+ for (let i: number = 0; i < 5; i++) {
461
+ await act(async () => {
462
+ await Promise.resolve();
463
+ });
464
+ }
465
+ }
466
+
467
+ async function renderPage(): Promise<void> {
468
+ render(<RumApplicationOverview {...({} as PageComponentProps)} />);
469
+ await screen.findByText("Checkout Web");
470
+ await flush();
471
+ }
472
+
473
+ /*
474
+ * A tile's root carries aria-busy; a chart card's does not. Both put the
475
+ * (i) beside the title, named "About <title>".
476
+ */
477
+ function infoButtons(title: string): Array<HTMLElement> {
478
+ return screen.getAllByRole("button", { name: `About ${title}` });
479
+ }
480
+
481
+ function tileInfo(title: string): HTMLElement {
482
+ const found: Array<HTMLElement> = infoButtons(title).filter(
483
+ (b: HTMLElement): boolean => {
484
+ return b.closest("[aria-busy]") !== null;
485
+ },
486
+ );
487
+ expect(found).toHaveLength(1);
488
+ return found[0]!;
489
+ }
490
+
491
+ function chartInfo(title: string): HTMLElement {
492
+ const found: Array<HTMLElement> = infoButtons(title).filter(
493
+ (b: HTMLElement): boolean => {
494
+ return b.closest("[aria-busy]") === null;
495
+ },
496
+ );
497
+ expect(found).toHaveLength(1);
498
+ return found[0]!;
499
+ }
500
+
501
+ function tile(title: string): HTMLElement {
502
+ return tileInfo(title).closest("[aria-busy]") as HTMLElement;
503
+ }
504
+
505
+ function expectTile(title: string, value: string, sublabel: string): void {
506
+ const root: HTMLElement = tile(title);
507
+
508
+ expect(root).toHaveAttribute("aria-busy", "false");
509
+ expect(within(root).getByText(value)).toBeInTheDocument();
510
+ expect(within(root).getByText(sublabel)).toBeInTheDocument();
511
+ }
512
+
513
+ async function tooltipTextOf(button: HTMLElement): Promise<string> {
514
+ fireEvent.mouseEnter(button);
515
+ await act(async () => {
516
+ jest.advanceTimersByTime(200);
517
+ });
518
+
519
+ const describedBy: string | null = button.getAttribute("aria-describedby");
520
+ expect(describedBy).toBeTruthy();
521
+
522
+ const text: string =
523
+ document.getElementById(describedBy as string)?.textContent || "";
524
+
525
+ fireEvent.mouseLeave(button);
526
+
527
+ return text;
528
+ }
529
+
530
+ type ChartProps = {
531
+ data: Array<{ seriesName: string; data: Array<TimePoint> }>;
532
+ };
533
+
534
+ function chartWithSeries(names: Array<string>): ChartProps {
535
+ const calls: Array<ChartProps> = lineChartMock.mock.calls.map(
536
+ (call: Array<unknown>): ChartProps => {
537
+ return call[0] as ChartProps;
538
+ },
539
+ );
540
+ const matching: Array<ChartProps> = calls.filter(
541
+ (props: ChartProps): boolean => {
542
+ return (
543
+ JSON.stringify(
544
+ props.data.map((s: { seriesName: string }) => {
545
+ return s.seriesName;
546
+ }),
547
+ ) === JSON.stringify(names)
548
+ );
549
+ },
550
+ );
551
+ expect(matching.length).toBeGreaterThan(0);
552
+ return matching[matching.length - 1]!;
553
+ }
554
+
555
+ beforeEach(() => {
556
+ jest.useFakeTimers();
557
+ for (const mock of [
558
+ getItemMock,
559
+ countMock,
560
+ fetchSpanMetricsMock,
561
+ fetchSpanNameStatsMock,
562
+ fetchWebVitalsMock,
563
+ fetchSignalsMock,
564
+ fetchSessionReplayListMock,
565
+ lineChartMock,
566
+ ]) {
567
+ mock.mockReset();
568
+ }
569
+ });
570
+
571
+ afterEach(() => {
572
+ cleanup();
573
+ jest.useRealTimers();
574
+ });
575
+
576
+ describe("RUM overview: normal data", () => {
577
+ test("every tile shows the value and the line under it that its data supports", async () => {
578
+ arrange();
579
+ await renderPage();
580
+
581
+ expectTile("Page loads", "1.2k", "3 failed");
582
+ expectTile("Page load time (p95)", "2.34 s", "median 850 ms");
583
+ expectTile("Events", "45.6k", "spans, selected range");
584
+ expectTile("Error rate", "2.0%", "912 errored");
585
+ expectTile(
586
+ "Event duration (p95)",
587
+ "123 ms",
588
+ "page loads, requests, clicks",
589
+ );
590
+ expectTile("Exceptions", "17", "reported by your app");
591
+ expectTile("Clients", "4", "platforms seen");
592
+ expectTile("Sessions recorded", "50+", "past 1 hour");
593
+ });
594
+
595
+ test("the tiles are in the order a reader scans them, page loads first", async () => {
596
+ arrange();
597
+ await renderPage();
598
+
599
+ const titles: Array<string> = Array.from(
600
+ document.querySelectorAll("[aria-busy]"),
601
+ ).map((root: Element): string => {
602
+ return root.querySelector("span.uppercase")?.textContent || "";
603
+ });
604
+
605
+ expect(titles).toEqual(
606
+ TILE_TITLES.map(([title]: [string, RumMetric]) => {
607
+ return title;
608
+ }),
609
+ );
610
+ });
611
+
612
+ test("'Page views' is gone - the tile that counted every span is called Events", async () => {
613
+ arrange();
614
+ await renderPage();
615
+
616
+ expect(screen.queryByText(/page views/i)).not.toBeInTheDocument();
617
+ });
618
+
619
+ test("a page load count without failures says 'full page loads'", async () => {
620
+ arrange({
621
+ pageLoadStats: Promise.resolve({ ...PAGE_LOAD_STATS, errorCount: 0 }),
622
+ });
623
+ await renderPage();
624
+
625
+ expectTile("Page loads", "1.2k", "full page loads");
626
+ });
627
+
628
+ test("fewer than 50 recorded sessions show the exact count, without a '+'", async () => {
629
+ arrange({
630
+ sessionList: Promise.resolve({
631
+ sessions: sessions(7),
632
+ nextCursor: null,
633
+ ignoredFilters: [],
634
+ }),
635
+ });
636
+ await renderPage();
637
+
638
+ expectTile("Sessions recorded", "7", "past 1 hour");
639
+ });
640
+
641
+ test("the error-rate bar is coloured by its 1% / 5% thresholds", async () => {
642
+ arrange();
643
+ await renderPage();
644
+
645
+ const bar: HTMLElement | null = tile("Error rate").querySelector(
646
+ "[style]",
647
+ ) as HTMLElement | null;
648
+
649
+ expect(bar).not.toBeNull();
650
+ // 2% is past the 1% warning line and short of the 5% danger line.
651
+ expect(bar).toHaveClass("bg-amber-500");
652
+ expect(bar!.style.width).toBe("2%");
653
+ });
654
+
655
+ test("page loads are fetched as documentLoad spans of this application, for the same window", async () => {
656
+ arrange();
657
+ await renderPage();
658
+
659
+ expect(fetchSpanNameStatsMock).toHaveBeenCalledTimes(1);
660
+ const statsScope: {
661
+ primaryEntityId: ObjectID;
662
+ spanName: string;
663
+ start: Date;
664
+ end: Date;
665
+ } = fetchSpanNameStatsMock.mock.calls[0]![0];
666
+
667
+ expect(statsScope.spanName).toBe("documentLoad");
668
+ expect(statsScope.primaryEntityId.toString()).toBe(MODEL_ID);
669
+
670
+ const spanScopes: Array<{
671
+ primaryEntityId: ObjectID;
672
+ spanName?: string;
673
+ start: Date;
674
+ end: Date;
675
+ }> = fetchSpanMetricsMock.mock.calls.map((call: Array<unknown>) => {
676
+ return call[0] as {
677
+ primaryEntityId: ObjectID;
678
+ spanName?: string;
679
+ start: Date;
680
+ end: Date;
681
+ };
682
+ });
683
+
684
+ expect(spanScopes).toHaveLength(2);
685
+ expect(
686
+ spanScopes
687
+ .map((s: { spanName?: string }) => {
688
+ return s.spanName ?? "(all spans)";
689
+ })
690
+ .sort(),
691
+ ).toEqual(["(all spans)", "documentLoad"]);
692
+
693
+ for (const scope of spanScopes) {
694
+ expect(scope.primaryEntityId.toString()).toBe(MODEL_ID);
695
+ expect(scope.start.getTime()).toBe(statsScope.start.getTime());
696
+ expect(scope.end.getTime()).toBe(statsScope.end.getTime());
697
+ }
698
+
699
+ const signalsScope: { primaryEntityId: ObjectID; start: Date } =
700
+ fetchSignalsMock.mock.calls[0]![0];
701
+ expect(signalsScope.primaryEntityId.toString()).toBe(MODEL_ID);
702
+ expect(signalsScope.start.getTime()).toBe(statsScope.start.getTime());
703
+ });
704
+
705
+ test("each chart is handed the series its title promises", async () => {
706
+ arrange();
707
+ await renderPage();
708
+
709
+ expect(
710
+ chartWithSeries(["Page loads", "Failed"]).data.map(
711
+ (s: { data: Array<TimePoint> }) => {
712
+ return s.data;
713
+ },
714
+ ),
715
+ ).toEqual([PAGE_LOAD_SERIES.countSeries, PAGE_LOAD_SERIES.errorSeries]);
716
+
717
+ expect(chartWithSeries(["Events", "Errors"]).data[0]!.data).toEqual(
718
+ ALL_SPANS.countSeries,
719
+ );
720
+
721
+ const p95Charts: Array<ChartProps> = lineChartMock.mock.calls
722
+ .map((call: Array<unknown>): ChartProps => {
723
+ return call[0] as ChartProps;
724
+ })
725
+ .filter((props: ChartProps): boolean => {
726
+ return props.data.length === 1 && props.data[0]!.seriesName === "p95";
727
+ });
728
+ const p95Data: Array<Array<TimePoint>> = p95Charts.map(
729
+ (props: ChartProps) => {
730
+ return props.data[0]!.data;
731
+ },
732
+ );
733
+ expect(p95Data).toContainEqual(PAGE_LOAD_SERIES.p95Series);
734
+ expect(p95Data).toContainEqual(ALL_SPANS.p95Series);
735
+
736
+ // Handled and unhandled are drawn as one line, summed per timestamp.
737
+ expect(chartWithSeries(["Exceptions"]).data[0]!.data).toEqual(
738
+ points([T0, 6], [T1, 8], [T2, 3]),
739
+ );
740
+
741
+ expect(
742
+ chartWithSeries(["Log lines", "Errors"]).data.map(
743
+ (s: { data: Array<TimePoint> }) => {
744
+ return s.data;
745
+ },
746
+ ),
747
+ ).toEqual([SIGNALS.logs.countSeries, SIGNALS.logs.errorSeries]);
748
+ });
749
+
750
+ test("the recording-health line still sits in the details", async () => {
751
+ arrange();
752
+ await renderPage();
753
+
754
+ expect(screen.getByText("Recording health")).toBeInTheDocument();
755
+ expect(screen.getByText("Recording healthy")).toBeInTheDocument();
756
+ });
757
+ });
758
+
759
+ describe("RUM overview: every tile and chart explains itself", () => {
760
+ test("eight tiles and six charts each carry exactly one (i)", async () => {
761
+ arrange();
762
+ await renderPage();
763
+
764
+ for (const [title] of TILE_TITLES) {
765
+ tileInfo(title);
766
+ }
767
+ for (const [title] of CHART_TITLES) {
768
+ chartInfo(title);
769
+ }
770
+
771
+ const tileRoots: Array<Element> = Array.from(
772
+ document.querySelectorAll("[aria-busy]"),
773
+ );
774
+ expect(tileRoots).toHaveLength(8);
775
+ for (const root of tileRoots) {
776
+ expect(
777
+ within(root as HTMLElement).getAllByRole("button", { name: /^About / }),
778
+ ).toHaveLength(1);
779
+ }
780
+ });
781
+
782
+ test.each(TILE_TITLES)(
783
+ "the %s tile's tooltip is RUM_METRIC_DESCRIPTIONS.%s",
784
+ async (title: string, key: RumMetric) => {
785
+ arrange();
786
+ await renderPage();
787
+
788
+ expect(await tooltipTextOf(tileInfo(title))).toBe(
789
+ RUM_METRIC_DESCRIPTIONS[key],
790
+ );
791
+ },
792
+ );
793
+
794
+ test.each(CHART_TITLES)(
795
+ "the %s chart's tooltip is RUM_METRIC_DESCRIPTIONS.%s",
796
+ async (title: string, key: RumMetric) => {
797
+ arrange();
798
+ await renderPage();
799
+
800
+ expect(await tooltipTextOf(chartInfo(title))).toBe(
801
+ RUM_METRIC_DESCRIPTIONS[key],
802
+ );
803
+ },
804
+ );
805
+
806
+ test("the Core Web Vitals card explains itself, and each vital explains itself and its limits", async () => {
807
+ arrange();
808
+ await renderPage();
809
+
810
+ expect(
811
+ await tooltipTextOf(
812
+ screen.getByRole("button", { name: "About Core Web Vitals" }),
813
+ ),
814
+ ).toBe(RUM_METRIC_DESCRIPTIONS.webVitals);
815
+
816
+ const lcp: HTMLElement = screen.getByRole("button", {
817
+ name: "About LCP (Largest Contentful Paint)",
818
+ });
819
+ const text: string = await tooltipTextOf(lcp);
820
+
821
+ expect(text).toContain(WebVitalDefinitions[0]!.description);
822
+ expect(text).toContain("Good below 2.5 s; poor at 4 s or more.");
823
+
824
+ for (const d of WebVitalDefinitions) {
825
+ expect(
826
+ screen.getByRole("button", {
827
+ name: `About ${d.key.toUpperCase()} (${d.label})`,
828
+ }),
829
+ ).toBeInTheDocument();
830
+ }
831
+ });
832
+
833
+ test("tiles still loading already carry their (i)", async () => {
834
+ arrange({
835
+ allSpans: never<SpanMetrics>(),
836
+ pageLoadSeries: never<SpanMetrics>(),
837
+ pageLoadStats: never<SpanNameStats>(),
838
+ signals: never<LogAndExceptionSignals>(),
839
+ clientCount: never<number>(),
840
+ sessionList: never<unknown>(),
841
+ vitals: never<Array<WebVital>>(),
842
+ });
843
+ await renderPage();
844
+
845
+ for (const [title] of TILE_TITLES) {
846
+ expect(tile(title)).toHaveAttribute("aria-busy", "true");
847
+ expect(
848
+ within(tile(title)).getByText(`Loading ${title}`),
849
+ ).toBeInTheDocument();
850
+ }
851
+ for (const [title] of CHART_TITLES) {
852
+ chartInfo(title);
853
+ }
854
+ expect(screen.queryByTestId("line-chart")).not.toBeInTheDocument();
855
+ });
856
+ });
857
+
858
+ describe("RUM overview: linked tiles", () => {
859
+ test("Clients links to the clients list through an overlay, and its (i) is not inside the link", async () => {
860
+ arrange();
861
+ await renderPage();
862
+
863
+ const link: HTMLElement = screen.getByRole("link", {
864
+ name: "View Clients",
865
+ });
866
+
867
+ expect(link).toHaveAttribute(
868
+ "href",
869
+ `/dashboard/${PROJECT_ID}/rum/${MODEL_ID}/clients`,
870
+ );
871
+ expect(link).toHaveClass("absolute", "inset-0");
872
+ expect(tile("Clients")).toContainElement(link);
873
+
874
+ const info: HTMLElement = tileInfo("Clients");
875
+ expect(info.closest("a")).toBeNull();
876
+ expect(link).not.toContainElement(info);
877
+ expect(info).toHaveClass("relative", "z-10");
878
+ });
879
+
880
+ test("Sessions recorded links to Session Replay with the counted range, and its (i) is not inside the link", async () => {
881
+ arrange();
882
+ await renderPage();
883
+
884
+ const link: HTMLElement = screen.getByRole("link", {
885
+ name: "View Sessions recorded",
886
+ });
887
+
888
+ expect(link.getAttribute("href")).toBe(
889
+ `/dashboard/${PROJECT_ID}/rum/${MODEL_ID}/session-replay?range=Past%201%20Hour`,
890
+ );
891
+ expect(tileInfo("Sessions recorded").closest("a")).toBeNull();
892
+ });
893
+
894
+ test("no (i) anywhere on the page is nested in a link or another button", async () => {
895
+ arrange();
896
+ await renderPage();
897
+
898
+ const infos: Array<HTMLElement> = screen.getAllByRole("button", {
899
+ name: /^About /,
900
+ });
901
+
902
+ // 8 tiles + 6 charts + the web-vitals card + 5 vitals.
903
+ expect(infos).toHaveLength(20);
904
+ for (const info of infos) {
905
+ expect(info.closest("a")).toBeNull();
906
+ expect(info.parentElement?.closest("button")).toBeNull();
907
+ }
908
+ });
909
+
910
+ test("only Clients and Sessions recorded are links", async () => {
911
+ arrange();
912
+ await renderPage();
913
+
914
+ const tileLinks: Array<string> = Array.from(
915
+ document.querySelectorAll("[aria-busy] a"),
916
+ ).map((a: Element): string => {
917
+ return a.textContent || "";
918
+ });
919
+
920
+ expect(tileLinks).toEqual(["View Clients", "View Sessions recorded"]);
921
+ });
922
+ });
923
+
924
+ describe("RUM overview: empty and failed lookups", () => {
925
+ test("an app with events but no documentLoad spans names the missing span", async () => {
926
+ arrange({
927
+ pageLoadStats: Promise.resolve({
928
+ count: 0,
929
+ errorCount: 0,
930
+ avgDurationMs: 0,
931
+ p50DurationMs: 0,
932
+ p95DurationMs: 0,
933
+ p99DurationMs: 0,
934
+ }),
935
+ pageLoadSeries: Promise.resolve({
936
+ ...PAGE_LOAD_SERIES,
937
+ total: 0,
938
+ errors: 0,
939
+ countSeries: [],
940
+ errorSeries: [],
941
+ p95Series: [],
942
+ }),
943
+ });
944
+ await renderPage();
945
+
946
+ expectTile("Page loads", "0", "no documentLoad spans");
947
+ expectTile("Page load time (p95)", "—", "no page loads");
948
+ // The other tiles are unaffected.
949
+ expectTile("Events", "45.6k", "spans, selected range");
950
+ });
951
+
952
+ test("an app with no events at all shows 0 page loads without blaming a missing span", async () => {
953
+ arrange({
954
+ allSpans: Promise.resolve({
955
+ total: 0,
956
+ errors: 0,
957
+ errorRatePercent: null,
958
+ p95DurationMs: null,
959
+ countSeries: [],
960
+ errorSeries: [],
961
+ p95Series: [],
962
+ }),
963
+ pageLoadStats: Promise.resolve({
964
+ ...PAGE_LOAD_STATS,
965
+ count: 0,
966
+ errorCount: 0,
967
+ }),
968
+ });
969
+ await renderPage();
970
+
971
+ expectTile("Page loads", "0", "full page loads");
972
+ expectTile("Page load time (p95)", "—", "no page loads");
973
+ expectTile("Events", "0", "spans, selected range");
974
+ expect(within(tile("Error rate")).getByText("—")).toBeInTheDocument();
975
+ expect(
976
+ within(tile("Event duration (p95)")).getByText("—"),
977
+ ).toBeInTheDocument();
978
+ });
979
+
980
+ test("a failed page-load stats request says 'could not load' on both page-load tiles", async () => {
981
+ arrange({ pageLoadStats: rejected("403") });
982
+ await renderPage();
983
+
984
+ expectTile("Page loads", "—", "could not load");
985
+ expectTile("Page load time (p95)", "—", "could not load");
986
+ // Nothing else is dragged down with it.
987
+ expectTile("Events", "45.6k", "spans, selected range");
988
+ });
989
+
990
+ /*
991
+ * fetchSpanMetrics never rejects: a failed aggregate resolves an empty
992
+ * result with `failed` set. Its zeros must not reach the three span tiles.
993
+ */
994
+ test("a failed span lookup reads 'could not load' on Events, Error rate and Event duration - never 0", async () => {
995
+ arrange({
996
+ allSpans: Promise.resolve({
997
+ total: 0,
998
+ errors: 0,
999
+ errorRatePercent: null,
1000
+ p95DurationMs: null,
1001
+ countSeries: [],
1002
+ errorSeries: [],
1003
+ p95Series: [],
1004
+ failed: true,
1005
+ }),
1006
+ });
1007
+ await renderPage();
1008
+
1009
+ expectTile("Events", "—", "could not load");
1010
+ expectTile("Error rate", "—", "could not load");
1011
+ expectTile("Event duration (p95)", "—", "could not load");
1012
+ expect(within(tile("Events")).queryByText("0")).not.toBeInTheDocument();
1013
+ // The page-load tiles come from their own request and are unaffected.
1014
+ expectTile("Page loads", "1.2k", "3 failed");
1015
+ });
1016
+
1017
+ test("a failed page-load series request also leaves the page-load tiles unknown", async () => {
1018
+ arrange({ pageLoadSeries: rejected("500") });
1019
+ await renderPage();
1020
+
1021
+ expectTile("Page loads", "—", "could not load");
1022
+ expectTile("Page load time (p95)", "—", "could not load");
1023
+ });
1024
+
1025
+ test("a failed exceptions lookup is unknown, not zero", async () => {
1026
+ arrange({
1027
+ signals: Promise.resolve({
1028
+ logs: SIGNALS.logs,
1029
+ exceptions: {
1030
+ total: 0,
1031
+ unhandledCount: 0,
1032
+ unhandledSeries: [],
1033
+ handledSeries: [],
1034
+ failed: true,
1035
+ },
1036
+ }),
1037
+ });
1038
+ await renderPage();
1039
+
1040
+ expectTile("Exceptions", "—", "could not load");
1041
+ });
1042
+
1043
+ test("zero exceptions that really are zero show 0", async () => {
1044
+ arrange({
1045
+ signals: Promise.resolve({
1046
+ logs: SIGNALS.logs,
1047
+ exceptions: {
1048
+ total: 0,
1049
+ unhandledCount: 0,
1050
+ unhandledSeries: [],
1051
+ handledSeries: [],
1052
+ failed: false,
1053
+ },
1054
+ }),
1055
+ });
1056
+ await renderPage();
1057
+
1058
+ expectTile("Exceptions", "0", "reported by your app");
1059
+ });
1060
+
1061
+ test("a failed client count and a failed session list are unknown, not zero", async () => {
1062
+ arrange({
1063
+ clientCount: rejected("403"),
1064
+ sessionList: rejected("403"),
1065
+ });
1066
+ await renderPage();
1067
+
1068
+ expectTile("Clients", "—", "could not load");
1069
+ expectTile("Sessions recorded", "—", "could not load");
1070
+ });
1071
+
1072
+ test("no web vitals yet: the card still explains itself, the per-vital (i)s are absent", async () => {
1073
+ arrange({
1074
+ vitals: Promise.resolve(
1075
+ VITALS.map((v: WebVital): WebVital => {
1076
+ return { ...v, value: null };
1077
+ }),
1078
+ ),
1079
+ });
1080
+ await renderPage();
1081
+
1082
+ expect(screen.getByText("No web vitals reported yet")).toBeInTheDocument();
1083
+ expect(
1084
+ screen.getByRole("button", { name: "About Core Web Vitals" }),
1085
+ ).toBeInTheDocument();
1086
+ expect(
1087
+ screen.queryByRole("button", { name: /^About LCP/ }),
1088
+ ).not.toBeInTheDocument();
1089
+ });
1090
+ });
1091
+
1092
+ describe("RUM overview: the SDK-missing notice", () => {
1093
+ test("names the new tiles when only session replay is reporting", async () => {
1094
+ arrange({
1095
+ app: {
1096
+ ...APP,
1097
+ clientType: undefined,
1098
+ sdkLanguage: undefined,
1099
+ agentVersion: undefined,
1100
+ sessionReplayLastChunkReceivedAt: new Date(),
1101
+ },
1102
+ });
1103
+ await renderPage();
1104
+
1105
+ expect(
1106
+ screen.getByText("Session replay is reporting, the RUM SDK is not"),
1107
+ ).toBeInTheDocument();
1108
+ expect(
1109
+ screen.getByText(
1110
+ /Page loads, events, error rate, durations, exceptions and clients/,
1111
+ ),
1112
+ ).toBeInTheDocument();
1113
+ });
1114
+
1115
+ test("is absent once the SDK has reported", async () => {
1116
+ arrange();
1117
+ await renderPage();
1118
+
1119
+ expect(
1120
+ screen.queryByText("Session replay is reporting, the RUM SDK is not"),
1121
+ ).not.toBeInTheDocument();
1122
+ });
1123
+ });
1124
+
1125
+ describe("RUM overview: refreshing", () => {
1126
+ test("a manual refresh keeps every value on screen until its replacement arrives", async () => {
1127
+ arrange();
1128
+ await renderPage();
1129
+
1130
+ // The second round never answers.
1131
+ arrange({
1132
+ allSpans: never<SpanMetrics>(),
1133
+ pageLoadSeries: never<SpanMetrics>(),
1134
+ pageLoadStats: never<SpanNameStats>(),
1135
+ signals: never<LogAndExceptionSignals>(),
1136
+ sessionList: never<unknown>(),
1137
+ vitals: never<Array<WebVital>>(),
1138
+ });
1139
+
1140
+ fireEvent.click(screen.getByRole("button", { name: "Refresh now" }));
1141
+ await flush();
1142
+
1143
+ expect(fetchSpanNameStatsMock).toHaveBeenCalledTimes(2);
1144
+ expectTile("Page loads", "1.2k", "3 failed");
1145
+ expectTile("Page load time (p95)", "2.34 s", "median 850 ms");
1146
+ expectTile("Exceptions", "17", "reported by your app");
1147
+ expectTile("Sessions recorded", "50+", "past 1 hour");
1148
+ });
1149
+
1150
+ test("a range change reloads with spinners and names the new range on the sessions tile", async () => {
1151
+ arrange();
1152
+ await renderPage();
1153
+
1154
+ arrange({
1155
+ pageLoadStats: never<SpanNameStats>(),
1156
+ sessionList: Promise.resolve({
1157
+ sessions: sessions(3),
1158
+ nextCursor: null,
1159
+ ignoredFilters: [],
1160
+ }),
1161
+ });
1162
+
1163
+ fireEvent.click(screen.getByRole("button", { name: "Pick past day" }));
1164
+ await flush();
1165
+
1166
+ expect(tile("Page loads")).toHaveAttribute("aria-busy", "true");
1167
+ expect(tile("Page load time (p95)")).toHaveAttribute("aria-busy", "true");
1168
+ expectTile("Sessions recorded", "3", "past 1 day");
1169
+ });
1170
+
1171
+ test("a slow response from the previous range does not overwrite the new one", async () => {
1172
+ let resolveOld: (stats: SpanNameStats) => void = () => {};
1173
+ arrange({
1174
+ pageLoadStats: new Promise<SpanNameStats>(
1175
+ (resolve: (stats: SpanNameStats) => void) => {
1176
+ resolveOld = resolve;
1177
+ },
1178
+ ),
1179
+ });
1180
+ await renderPage();
1181
+
1182
+ arrange({
1183
+ pageLoadStats: Promise.resolve({ ...PAGE_LOAD_STATS, count: 42 }),
1184
+ });
1185
+ fireEvent.click(screen.getByRole("button", { name: "Pick past day" }));
1186
+ await flush();
1187
+
1188
+ expectTile("Page loads", "42", "3 failed");
1189
+
1190
+ resolveOld({ ...PAGE_LOAD_STATS, count: 999999 });
1191
+ await flush();
1192
+
1193
+ expectTile("Page loads", "42", "3 failed");
1194
+ });
1195
+ });