@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,887 @@
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ jest,
7
+ test,
8
+ } from "@jest/globals";
9
+ import "@testing-library/jest-dom";
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 { MemoryRouter } from "react-router-dom";
20
+
21
+ /*
22
+ * The (i) tooltips on the Cloud pages: the environment overview (six
23
+ * tiles, two charts and the "Top instances by CPU" card) and the fleet
24
+ * strip above the Cloud Environments list. The texts are held to the
25
+ * shared rules and to what the pages compute - two clocks on one overview,
26
+ * a live snapshot and the selected range - and the pages are rendered for
27
+ * real over a fake API to show each text beside the right title.
28
+ *
29
+ * jest.mock is hoisted above the imports, so the factories only close over
30
+ * names that start with "mock".
31
+ */
32
+
33
+ interface MockAggregateCall {
34
+ modelType: { name: string };
35
+ aggregateBy: {
36
+ aggregationType: string;
37
+ query: Record<string, unknown>;
38
+ };
39
+ }
40
+
41
+ interface MockCountCall {
42
+ modelType: { name: string };
43
+ query: Record<string, unknown>;
44
+ }
45
+
46
+ interface MockInstanceRow {
47
+ instanceName: string;
48
+ latestCpuPercent?: number | undefined;
49
+ latestMemoryBytes?: number | undefined;
50
+ // Minutes before now the instance was last seen.
51
+ minutesAgo: number;
52
+ }
53
+
54
+ interface MockListCall {
55
+ modelType: { name: string };
56
+ sort?: Record<string, unknown> | undefined;
57
+ }
58
+
59
+ const mockAggregateCalls: Array<MockAggregateCall> = [];
60
+ const mockListCalls: Array<MockListCall> = [];
61
+ const mockTranslations: Record<string, string> = {};
62
+
63
+ const MOCK_MIB: number = 1024 * 1024;
64
+
65
+ /*
66
+ * The instance rows getList returns, in the order the server sends them
67
+ * (it sorts by latestCpuPercent descending). Reset before every test.
68
+ */
69
+ const MOCK_DEFAULT_INSTANCES: Array<MockInstanceRow> = [
70
+ {
71
+ instanceName: "task-a",
72
+ latestCpuPercent: 80,
73
+ latestMemoryBytes: 512 * MOCK_MIB,
74
+ minutesAgo: 1,
75
+ },
76
+ {
77
+ instanceName: "task-b",
78
+ latestCpuPercent: 20,
79
+ latestMemoryBytes: 256 * MOCK_MIB,
80
+ minutesAgo: 2,
81
+ },
82
+ {
83
+ // Stale: outside the live window, so in no tile and no row.
84
+ instanceName: "task-gone",
85
+ latestCpuPercent: 99,
86
+ latestMemoryBytes: 4096 * MOCK_MIB,
87
+ minutesAgo: 60,
88
+ },
89
+ ];
90
+ let mockInstances: Array<MockInstanceRow> = [...MOCK_DEFAULT_INSTANCES];
91
+ // Keep the span / metric fetches (or the instance list) pending forever.
92
+ let mockHoldAggregates: boolean = false;
93
+ let mockHoldInstances: boolean = false;
94
+
95
+ jest.mock("../../../UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI", () => {
96
+ return {
97
+ __esModule: true,
98
+ default: {
99
+ aggregate: (call: MockAggregateCall): Promise<unknown> => {
100
+ mockAggregateCalls.push(call);
101
+ if (mockHoldAggregates) {
102
+ return new Promise<unknown>(() => {});
103
+ }
104
+ const at: Date = new Date("2026-09-24T10:00:00.000Z");
105
+
106
+ if (call.modelType.name === "Metric") {
107
+ return Promise.resolve({
108
+ data: [{ timestamp: at, value: 900 * MOCK_MIB }],
109
+ });
110
+ }
111
+ if (call.aggregateBy.aggregationType === "P95") {
112
+ return Promise.resolve({ data: [{ timestamp: at, value: 2.5e8 }] });
113
+ }
114
+ if (call.aggregateBy.query["statusCode"] !== undefined) {
115
+ return Promise.resolve({ data: [{ timestamp: at, value: 3 }] });
116
+ }
117
+ return Promise.resolve({ data: [{ timestamp: at, value: 100 }] });
118
+ },
119
+ },
120
+ };
121
+ });
122
+
123
+ jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
124
+ return {
125
+ __esModule: true,
126
+ default: {
127
+ getItem: (): Promise<unknown> => {
128
+ return Promise.resolve({
129
+ name: "prod · us-east-1",
130
+ resourceIdentifier: "aws_ecs|123456789012|us-east-1",
131
+ otelCollectorStatus: "connected",
132
+ cloudPlatform: "aws_ecs",
133
+ cloudProvider: "aws",
134
+ cloudRegion: "us-east-1",
135
+ cloudAccountId: "123456789012",
136
+ });
137
+ },
138
+ getList: (call: MockListCall): Promise<unknown> => {
139
+ mockListCalls.push(call);
140
+ if (mockHoldInstances) {
141
+ return new Promise<unknown>(() => {});
142
+ }
143
+ const now: number = Date.now();
144
+ return Promise.resolve({
145
+ data: mockInstances.map(
146
+ (row: MockInstanceRow): Record<string, unknown> => {
147
+ return {
148
+ instanceName: row.instanceName,
149
+ latestCpuPercent: row.latestCpuPercent,
150
+ latestMemoryBytes: row.latestMemoryBytes,
151
+ lastSeenAt: new Date(now - row.minutesAgo * 60 * 1000),
152
+ };
153
+ },
154
+ ),
155
+ });
156
+ },
157
+ count: (call: MockCountCall): Promise<number> => {
158
+ if (call.modelType.name === "CloudResourceInstance") {
159
+ return Promise.resolve(12);
160
+ }
161
+ if (call.query["otelCollectorStatus"] === "connected") {
162
+ return Promise.resolve(3);
163
+ }
164
+ if (call.query["cloudProvider"] === "aws") {
165
+ return Promise.resolve(4);
166
+ }
167
+ if (call.query["cloudProvider"] === "gcp") {
168
+ return Promise.resolve(1);
169
+ }
170
+ if (typeof call.query["cloudProvider"] === "string") {
171
+ return Promise.resolve(0);
172
+ }
173
+ return Promise.resolve(5);
174
+ },
175
+ },
176
+ };
177
+ });
178
+
179
+ jest.mock("../../../UI/Utils/Project", () => {
180
+ const ObjectIDModule: { default: new (id: string) => unknown } =
181
+ jest.requireActual("../../../Types/ObjectID") as {
182
+ default: new (id: string) => unknown;
183
+ };
184
+ return {
185
+ __esModule: true,
186
+ default: {
187
+ getCurrentProjectId: (): unknown => {
188
+ return new ObjectIDModule.default(
189
+ "aaaaaaaa-0000-4000-8000-000000000001",
190
+ );
191
+ },
192
+ },
193
+ };
194
+ });
195
+
196
+ jest.mock("../../../UI/Utils/Navigation", () => {
197
+ const ObjectIDModule: { default: new (id: string) => unknown } =
198
+ jest.requireActual("../../../Types/ObjectID") as {
199
+ default: new (id: string) => unknown;
200
+ };
201
+ return {
202
+ __esModule: true,
203
+ default: {
204
+ getLastParamAsObjectID: (): unknown => {
205
+ return new ObjectIDModule.default(
206
+ "cccccccc-0000-4000-8000-000000000003",
207
+ );
208
+ },
209
+ },
210
+ };
211
+ });
212
+
213
+ jest.mock("../../../UI/Utils/API/API", () => {
214
+ return {
215
+ __esModule: true,
216
+ default: {
217
+ getFriendlyMessage: (): string => {
218
+ return "failed";
219
+ },
220
+ },
221
+ };
222
+ });
223
+
224
+ jest.mock("react-i18next", () => {
225
+ return {
226
+ useTranslation: () => {
227
+ return {
228
+ t: (value: string): string => {
229
+ return mockTranslations[value] ?? value;
230
+ },
231
+ };
232
+ },
233
+ };
234
+ });
235
+
236
+ jest.mock("../../../UI/Components/Charts/Line/LineChart", () => {
237
+ return {
238
+ __esModule: true,
239
+ default: (): React.ReactElement => {
240
+ return React.createElement("div", { "data-testid": "line-chart" });
241
+ },
242
+ };
243
+ });
244
+
245
+ jest.mock(
246
+ "../../../UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker",
247
+ () => {
248
+ return {
249
+ __esModule: true,
250
+ default: (): null => {
251
+ return null;
252
+ },
253
+ };
254
+ },
255
+ );
256
+
257
+ jest.mock(
258
+ "../../../../App/FeatureSet/Dashboard/src/Components/TelemetryResource/AutoRefreshControl",
259
+ () => {
260
+ return {
261
+ __esModule: true,
262
+ default: (): null => {
263
+ return null;
264
+ },
265
+ };
266
+ },
267
+ );
268
+
269
+ import CloudResourceOverview, {
270
+ TopInstancesByCpuTitle,
271
+ } from "../../../../App/FeatureSet/Dashboard/src/Pages/Cloud/View/Overview";
272
+ import CloudFleetSummary from "../../../../App/FeatureSet/Dashboard/src/Components/Cloud/CloudFleetSummary";
273
+ import {
274
+ CloudFleetSummaryTile,
275
+ summarizeCloudFleet,
276
+ } from "../../../../App/FeatureSet/Dashboard/src/Pages/Cloud/Utils/CloudFleetSummary";
277
+ import { CLOUD_INSTANCE_LIVE_WINDOW_MINUTES } from "../../../../App/FeatureSet/Dashboard/src/Pages/Cloud/Utils/CloudResourceTelemetryScope";
278
+ import {
279
+ CLOUD_FLEET_METRIC_DESCRIPTIONS,
280
+ CLOUD_INSTANCE_METRIC_DESCRIPTIONS,
281
+ CLOUD_METRIC_DESCRIPTIONS,
282
+ CloudFleetMetric,
283
+ CloudMetric,
284
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/MetricDescriptions/CloudMetricDescriptions";
285
+ import {
286
+ expectReadableDescriptionRecord,
287
+ expectTitleExplained,
288
+ } from "./MetricDescriptionRules";
289
+ import PageComponentProps from "../../../../App/FeatureSet/Dashboard/src/Pages/PageComponentProps";
290
+ import Route from "../../../Types/API/Route";
291
+ import SortOrder from "../../../Types/BaseDatabase/SortOrder";
292
+
293
+ const PAGE_PROPS: PageComponentProps = {
294
+ pageRoute: new Route("/cloud/overview"),
295
+ currentProject: null,
296
+ hasPaymentMethod: false,
297
+ };
298
+
299
+ const D: Record<CloudMetric, string> = CLOUD_METRIC_DESCRIPTIONS;
300
+ const F: Record<CloudFleetMetric, string> = CLOUD_FLEET_METRIC_DESCRIPTIONS;
301
+
302
+ const LIVE_WINDOW: string = `${CLOUD_INSTANCE_LIVE_WINDOW_MINUTES} minutes`;
303
+
304
+ // Title on the overview -> the text its (i) must show, in page order.
305
+ const TILES: Array<[string, CloudMetric]> = [
306
+ ["CPU", "cpu"],
307
+ ["Memory", "memory"],
308
+ ["Instances", "instances"],
309
+ ["Requests", "requests"],
310
+ ["Error rate", "errorRate"],
311
+ ["p95 latency", "p95Latency"],
312
+ ];
313
+
314
+ const CHARTS: Array<[string, CloudMetric]> = [
315
+ ["Requests", "requestsChart"],
316
+ ["Memory", "memoryChart"],
317
+ ];
318
+
319
+ const TOP_INSTANCES: [string, CloudMetric] = [
320
+ "Top instances by CPU",
321
+ "topInstancesByCpu",
322
+ ];
323
+
324
+ // The tiles that read each live instance's latest snapshot.
325
+ const SNAPSHOT_KEYS: Array<CloudMetric> = [
326
+ "cpu",
327
+ "memory",
328
+ "instances",
329
+ "topInstancesByCpu",
330
+ ];
331
+
332
+ // The tiles and charts that follow the time picker.
333
+ const RANGED_KEYS: Array<CloudMetric> = [
334
+ "requests",
335
+ "errorRate",
336
+ "p95Latency",
337
+ "requestsChart",
338
+ "memoryChart",
339
+ ];
340
+
341
+ const FLEET_TILES: Array<[string, CloudFleetMetric]> = [
342
+ ["Environments", "environments"],
343
+ ["Connected", "connected"],
344
+ ["Disconnected", "disconnected"],
345
+ ["Live instances", "liveInstances"],
346
+ ];
347
+
348
+ describe("CLOUD_METRIC_DESCRIPTIONS", () => {
349
+ test("every text reads as a short, finished, jargon-explaining sentence", () => {
350
+ expectReadableDescriptionRecord(D, "CLOUD_METRIC_DESCRIPTIONS");
351
+ });
352
+
353
+ test("covers exactly the tiles, charts and cards on the overview", () => {
354
+ expect(
355
+ [...TILES, ...CHARTS, TOP_INSTANCES]
356
+ .map((entry: [string, CloudMetric]): string => {
357
+ return entry[1];
358
+ })
359
+ .sort(),
360
+ ).toEqual(Object.keys(D).sort());
361
+ });
362
+
363
+ test.each([...TILES, ...CHARTS, TOP_INSTANCES])(
364
+ "the %s text explains any percentile its title names",
365
+ (title: string, key: CloudMetric) => {
366
+ expectTitleExplained(title, D[key]);
367
+ },
368
+ );
369
+
370
+ test("the two clocks are covered: every text belongs to exactly one", () => {
371
+ expect([...SNAPSHOT_KEYS, ...RANGED_KEYS].sort()).toEqual(
372
+ Object.keys(D).sort(),
373
+ );
374
+ });
375
+
376
+ test.each(SNAPSHOT_KEYS)(
377
+ "%s says it is a live snapshot over the live window, not the time range",
378
+ (key: CloudMetric) => {
379
+ expect(D[key]).toContain(LIVE_WINDOW);
380
+ expect(D[key]).toMatch(/ignores the selected time range/);
381
+ },
382
+ );
383
+
384
+ test.each(RANGED_KEYS)(
385
+ "%s follows the time range and never claims to ignore it",
386
+ (key: CloudMetric) => {
387
+ expect(D[key]).toMatch(/selected range|each interval/);
388
+ expect(D[key]).not.toMatch(/ignores the selected/);
389
+ },
390
+ );
391
+
392
+ test("CPU is an average of latest readings; Memory is a sum", () => {
393
+ expect(D.cpu).toMatch(/^Average of the latest CPU/);
394
+ // The page filters out instances whose latestCpuPercent is not a number.
395
+ expect(D.cpu).toMatch(/ones with none are left out/);
396
+ expect(D.memory).toMatch(/added up/);
397
+ expect(D.memory).toMatch(/latest reading/);
398
+ // A latest reading can be old; the text must not promise "right now".
399
+ expect(D.memory).not.toMatch(/right now/);
400
+ });
401
+
402
+ test("CPU says what 100% means and that it is not capped", () => {
403
+ /*
404
+ * latestCpuPercent is the receiver's own percentage (per vCPU / core
405
+ * for ECS task metrics and docker_stats), so a multi-core task reads
406
+ * above 100%.
407
+ */
408
+ // The same reading as the Instances tab's CPU column, so the same words.
409
+ for (const text of [D.cpu, CLOUD_INSTANCE_METRIC_DESCRIPTIONS.cpu]) {
410
+ expect(text).toContain(
411
+ "100% is one full CPU core or all the CPU the task was given, so it can read above 100%",
412
+ );
413
+ }
414
+ });
415
+
416
+ test("the p95 tile says each interval counts equally, like the other overview p95 tiles", () => {
417
+ expect(D.p95Latency).toContain(
418
+ "then averaged over the selected range, so quiet and busy intervals count equally",
419
+ );
420
+ });
421
+
422
+ test("Requests is honest that it counts spans across the environment", () => {
423
+ expect(D.requests).toMatch(/Every span/);
424
+ expect(D.requests).toMatch(/cloud platform, account and region/);
425
+ // "usually": a trace with a single span is one request and one span.
426
+ expect(D.requests).toMatch(/usually higher than the request count/);
427
+ });
428
+
429
+ test("the p95 tile is an average of per-interval p95s", () => {
430
+ expect(D.p95Latency).toMatch(/95%/);
431
+ expect(D.p95Latency).toMatch(/for each interval, then averaged/);
432
+ });
433
+
434
+ test("Error rate names the thresholds the tile uses", () => {
435
+ expect(D.errorRate).toContain("amber at 1%");
436
+ expect(D.errorRate).toContain("red at 5%");
437
+ });
438
+
439
+ test("the Memory chart warns that repeated readings add up", () => {
440
+ // The page fetches container.memory.usage with AggregationType.Sum.
441
+ expect(D.memoryChart).toContain("container.memory.usage");
442
+ expect(D.memoryChart).toMatch(/counts more than once/);
443
+ expect(D.memoryChart).toMatch(/trend, not a total/);
444
+ });
445
+
446
+ test("Top instances names its cap and its order", () => {
447
+ expect(D.topInstancesByCpu).toMatch(/Up to five/);
448
+ expect(D.topInstancesByCpu).toMatch(/highest first/);
449
+ });
450
+
451
+ test("Top instances is honest that a task with no CPU reading can lead", () => {
452
+ /*
453
+ * The list is sorted by latestCpuPercent DESC in Postgres, which puts
454
+ * NULLs first, and the page keeps the server's order.
455
+ */
456
+ expect(D.topInstancesByCpu).toMatch(
457
+ /not reported CPU show a dash and may be listed first/,
458
+ );
459
+ });
460
+ });
461
+
462
+ describe("CLOUD_FLEET_METRIC_DESCRIPTIONS", () => {
463
+ test("every text reads as a short, finished, jargon-explaining sentence", () => {
464
+ expectReadableDescriptionRecord(F, "CLOUD_FLEET_METRIC_DESCRIPTIONS");
465
+ });
466
+
467
+ test("covers exactly the four tiles summarizeCloudFleet returns", () => {
468
+ const tiles: Array<CloudFleetSummaryTile> = summarizeCloudFleet({
469
+ total: 0,
470
+ connected: 0,
471
+ disconnected: 0,
472
+ byProvider: {},
473
+ liveInstances: 0,
474
+ });
475
+
476
+ expect(
477
+ tiles.map((tile: CloudFleetSummaryTile): [string, string] => {
478
+ return [tile.title, tile.description];
479
+ }),
480
+ ).toEqual(
481
+ FLEET_TILES.map((entry: [string, CloudFleetMetric]): [string, string] => {
482
+ return [entry[0], F[entry[1]]];
483
+ }),
484
+ );
485
+ });
486
+
487
+ test("the description does not depend on the counts", () => {
488
+ const empty: Array<CloudFleetSummaryTile> = summarizeCloudFleet({
489
+ total: 0,
490
+ connected: 0,
491
+ disconnected: 0,
492
+ byProvider: {},
493
+ liveInstances: 0,
494
+ });
495
+ const busy: Array<CloudFleetSummaryTile> = summarizeCloudFleet({
496
+ total: 9,
497
+ connected: 2,
498
+ disconnected: 7,
499
+ byProvider: { aws: 9 },
500
+ liveInstances: 40,
501
+ });
502
+
503
+ expect(
504
+ busy.map((tile: CloudFleetSummaryTile): string => {
505
+ return tile.description;
506
+ }),
507
+ ).toEqual(
508
+ empty.map((tile: CloudFleetSummaryTile): string => {
509
+ return tile.description;
510
+ }),
511
+ );
512
+ });
513
+
514
+ test("Environments and Live instances say which rows they count", () => {
515
+ // fetchCloudFleetCounts: environments are isArchived=false; instances are not.
516
+ expect(F.environments).toMatch(/not counting archived ones/);
517
+ expect(F.liveInstances).toMatch(/archived ones included/);
518
+ expect(F.liveInstances).toContain(LIVE_WINDOW);
519
+ });
520
+
521
+ test("Connected and Disconnected describe the status sweep", () => {
522
+ expect(F.connected).toMatch(/15 to 20 minutes/);
523
+ expect(F.disconnected).toMatch(/never reported/);
524
+ });
525
+ });
526
+
527
+ async function hover(trigger: HTMLElement): Promise<void> {
528
+ fireEvent.mouseEnter(trigger);
529
+ await act(async () => {
530
+ jest.advanceTimersByTime(200);
531
+ });
532
+ }
533
+
534
+ function infoButtons(title: string): Array<HTMLElement> {
535
+ return screen.getAllByRole("button", { name: `About ${title}` });
536
+ }
537
+
538
+ async function renderOverview(): Promise<void> {
539
+ render(
540
+ <MemoryRouter>
541
+ <CloudResourceOverview {...PAGE_PROPS} />
542
+ </MemoryRouter>,
543
+ );
544
+
545
+ await screen.findByText("task-a");
546
+ await screen.findByText("250 ms");
547
+ }
548
+
549
+ describe("Cloud environment overview page", () => {
550
+ beforeEach(() => {
551
+ jest.useFakeTimers();
552
+ mockAggregateCalls.length = 0;
553
+ mockListCalls.length = 0;
554
+ mockInstances = [...MOCK_DEFAULT_INSTANCES];
555
+ mockHoldAggregates = false;
556
+ mockHoldInstances = false;
557
+ });
558
+
559
+ afterEach(() => {
560
+ cleanup();
561
+ jest.useRealTimers();
562
+ for (const key of Object.keys(mockTranslations)) {
563
+ delete mockTranslations[key];
564
+ }
565
+ });
566
+
567
+ test("renders one (i) per tile, per chart and for the top-instances card", async () => {
568
+ await renderOverview();
569
+
570
+ expect(screen.getAllByRole("button", { name: /^About / })).toHaveLength(
571
+ TILES.length + CHARTS.length + 1,
572
+ );
573
+ });
574
+
575
+ test("shows the numbers the descriptions promise", async () => {
576
+ await renderOverview();
577
+
578
+ // Live instances only: (80 + 20) / 2, 512 + 256 MiB, two tasks.
579
+ expect(screen.getByText("50.0%")).toBeInTheDocument();
580
+ expect(screen.getByText("768 MiB")).toBeInTheDocument();
581
+ expect(screen.queryByText("task-gone")).not.toBeInTheDocument();
582
+ });
583
+
584
+ test.each(TILES)(
585
+ "the %s tile explains itself on hover",
586
+ async (title: string, key: CloudMetric) => {
587
+ await renderOverview();
588
+
589
+ await hover(infoButtons(title)[0]!);
590
+
591
+ expect(screen.getByRole("tooltip")).toHaveTextContent(D[key]);
592
+ },
593
+ );
594
+
595
+ test.each(CHARTS)(
596
+ "the %s chart explains itself on hover",
597
+ async (title: string, key: CloudMetric) => {
598
+ await renderOverview();
599
+
600
+ const buttons: Array<HTMLElement> = infoButtons(title);
601
+
602
+ // The tile of the same name renders first.
603
+ expect(buttons).toHaveLength(2);
604
+
605
+ await hover(buttons[1]!);
606
+
607
+ expect(screen.getByRole("tooltip")).toHaveTextContent(D[key]);
608
+ },
609
+ );
610
+
611
+ test("the Top instances by CPU card explains itself from its heading", async () => {
612
+ await renderOverview();
613
+
614
+ const button: HTMLElement = screen.getByRole("button", {
615
+ name: `About ${TOP_INSTANCES[0]}`,
616
+ });
617
+
618
+ expect(button.closest("h2")).not.toBeNull();
619
+ expect(button.closest("h2")).toHaveTextContent(TOP_INSTANCES[0]);
620
+
621
+ await hover(button);
622
+
623
+ expect(screen.getByRole("tooltip")).toHaveTextContent(D.topInstancesByCpu);
624
+ });
625
+
626
+ test("no (i) sits inside a link or another button", async () => {
627
+ await renderOverview();
628
+
629
+ for (const button of screen.getAllByRole("button", { name: /^About / })) {
630
+ expect(button.closest("a")).toBeNull();
631
+ expect(button.parentElement?.closest("button") ?? null).toBeNull();
632
+ }
633
+ });
634
+
635
+ test("the Memory chart it explains really is a sum of container.memory.usage", async () => {
636
+ await renderOverview();
637
+
638
+ const metricCalls: Array<MockAggregateCall> = mockAggregateCalls.filter(
639
+ (call: MockAggregateCall): boolean => {
640
+ return call.modelType.name === "Metric";
641
+ },
642
+ );
643
+
644
+ expect(metricCalls).toHaveLength(1);
645
+ expect(metricCalls[0]!.aggregateBy.query["name"]).toBe(
646
+ "container.memory.usage",
647
+ );
648
+ expect(metricCalls[0]!.aggregateBy.aggregationType).toBe("Sum");
649
+ });
650
+
651
+ test("CPU leaves out a live task with no CPU reading, as its text says", async () => {
652
+ mockInstances = [
653
+ ...MOCK_DEFAULT_INSTANCES,
654
+ {
655
+ instanceName: "task-quiet",
656
+ latestMemoryBytes: 128 * MOCK_MIB,
657
+ minutesAgo: 3,
658
+ },
659
+ ];
660
+
661
+ await renderOverview();
662
+
663
+ // (80 + 20) / 2 - not (80 + 20 + 0) / 3 = 33.3%.
664
+ expect(screen.getByText("50.0%")).toBeInTheDocument();
665
+ expect(screen.queryByText("33.3%")).not.toBeInTheDocument();
666
+ // Memory still adds the quiet task: 512 + 256 + 128 MiB.
667
+ expect(screen.getByText("896 MiB")).toBeInTheDocument();
668
+ expect(D.cpu).toMatch(/ones with none are left out/);
669
+ });
670
+
671
+ test("CPU is not capped at 100%, as its text says", async () => {
672
+ mockInstances = [
673
+ {
674
+ instanceName: "task-a",
675
+ latestCpuPercent: 250,
676
+ latestMemoryBytes: 512 * MOCK_MIB,
677
+ minutesAgo: 1,
678
+ },
679
+ ];
680
+
681
+ await renderOverview();
682
+
683
+ // Tile and top-instances row both show the raw percentage.
684
+ expect(screen.getAllByText("250.0%").length).toBeGreaterThanOrEqual(2);
685
+ expect(D.cpu).toContain("can read above 100%");
686
+ });
687
+
688
+ test("Top instances keeps the server's CPU order, so a task with no CPU can lead", async () => {
689
+ // As Postgres returns ORDER BY latestCpuPercent DESC: NULLs first.
690
+ mockInstances = [
691
+ {
692
+ instanceName: "task-quiet",
693
+ latestMemoryBytes: 128 * MOCK_MIB,
694
+ minutesAgo: 3,
695
+ },
696
+ ...MOCK_DEFAULT_INSTANCES,
697
+ ];
698
+
699
+ await renderOverview();
700
+
701
+ const instanceList: Array<MockListCall> = mockListCalls.filter(
702
+ (call: MockListCall): boolean => {
703
+ return call.modelType.name === "CloudResourceInstance";
704
+ },
705
+ );
706
+
707
+ expect(instanceList).toHaveLength(1);
708
+ expect(instanceList[0]!.sort).toEqual({
709
+ latestCpuPercent: SortOrder.Descending,
710
+ });
711
+
712
+ const names: Array<string> = ["task-quiet", "task-a", "task-b"];
713
+ const rendered: Array<string> = names.filter((name: string): boolean => {
714
+ return screen.queryByText(name) !== null;
715
+ });
716
+
717
+ expect(rendered).toEqual(names);
718
+
719
+ const first: HTMLElement = screen.getByText("task-quiet");
720
+ const second: HTMLElement = screen.getByText("task-a");
721
+
722
+ // task-quiet is rendered before task-a.
723
+ expect(
724
+ first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING,
725
+ ).toBeTruthy();
726
+ // Its CPU cell is a dash, as the text says.
727
+ expect(
728
+ within(first.parentElement as HTMLElement).getByText("—"),
729
+ ).toBeInTheDocument();
730
+ expect(D.topInstancesByCpu).toMatch(/show a dash and may be listed first/);
731
+ });
732
+
733
+ test("every tile keeps its (i) while its numbers are still loading", async () => {
734
+ mockHoldAggregates = true;
735
+ mockHoldInstances = true;
736
+
737
+ render(
738
+ <MemoryRouter>
739
+ <CloudResourceOverview {...PAGE_PROPS} />
740
+ </MemoryRouter>,
741
+ );
742
+
743
+ await screen.findByText("Loading CPU");
744
+
745
+ for (const [title, key] of TILES) {
746
+ expect(screen.getByText(`Loading ${title}`)).toBeInTheDocument();
747
+
748
+ const button: HTMLElement = infoButtons(title)[0]!;
749
+
750
+ await hover(button);
751
+
752
+ const tooltips: Array<HTMLElement> = screen.getAllByRole("tooltip");
753
+
754
+ expect(tooltips[tooltips.length - 1]).toHaveTextContent(D[key]);
755
+ fireEvent.mouseLeave(button);
756
+ }
757
+
758
+ // The chart cards are loading too, and explain themselves as well.
759
+ for (const [title, key] of CHARTS) {
760
+ const buttons: Array<HTMLElement> = infoButtons(title);
761
+
762
+ await hover(buttons[buttons.length - 1]!);
763
+
764
+ const tooltips: Array<HTMLElement> = screen.getAllByRole("tooltip");
765
+
766
+ expect(tooltips[tooltips.length - 1]).toHaveTextContent(D[key]);
767
+ fireEvent.mouseLeave(buttons[buttons.length - 1]!);
768
+ }
769
+ });
770
+
771
+ test("the span tiles it explains are scoped by the cloud attributes", async () => {
772
+ await renderOverview();
773
+
774
+ const spanCalls: Array<MockAggregateCall> = mockAggregateCalls.filter(
775
+ (call: MockAggregateCall): boolean => {
776
+ return call.modelType.name === "Span";
777
+ },
778
+ );
779
+
780
+ expect(spanCalls.length).toBeGreaterThan(0);
781
+
782
+ for (const call of spanCalls) {
783
+ expect(call.aggregateBy.query["attributes"]).toEqual({
784
+ "resource.cloud.platform": "aws_ecs",
785
+ "resource.cloud.account.id": "123456789012",
786
+ "resource.cloud.region": "us-east-1",
787
+ });
788
+ }
789
+ });
790
+ });
791
+
792
+ describe("TopInstancesByCpuTitle", () => {
793
+ beforeEach(() => {
794
+ jest.useFakeTimers();
795
+ });
796
+
797
+ afterEach(() => {
798
+ cleanup();
799
+ jest.useRealTimers();
800
+ for (const key of Object.keys(mockTranslations)) {
801
+ delete mockTranslations[key];
802
+ }
803
+ });
804
+
805
+ test("renders the title and an (i) that explains it", async () => {
806
+ render(<TopInstancesByCpuTitle />);
807
+
808
+ expect(screen.getByText("Top instances by CPU")).toBeInTheDocument();
809
+
810
+ await hover(
811
+ screen.getByRole("button", { name: "About Top instances by CPU" }),
812
+ );
813
+
814
+ expect(screen.getByRole("tooltip")).toHaveTextContent(D.topInstancesByCpu);
815
+ });
816
+
817
+ test("keeps the translation a string title would have had", () => {
818
+ /*
819
+ * Card translates a string title but passes an element through, so the
820
+ * element has to translate itself or a localized dashboard loses it.
821
+ */
822
+ mockTranslations["Top instances by CPU"] = "Instancias con más CPU";
823
+
824
+ render(<TopInstancesByCpuTitle />);
825
+
826
+ expect(screen.getByText("Instancias con más CPU")).toBeInTheDocument();
827
+ expect(
828
+ screen.getByRole("button", { name: "About Instancias con más CPU" }),
829
+ ).toBeInTheDocument();
830
+ });
831
+ });
832
+
833
+ describe("Cloud fleet summary strip", () => {
834
+ beforeEach(() => {
835
+ jest.useFakeTimers();
836
+ });
837
+
838
+ afterEach(() => {
839
+ cleanup();
840
+ jest.useRealTimers();
841
+ });
842
+
843
+ async function renderStrip(): Promise<void> {
844
+ render(<CloudFleetSummary />);
845
+ await screen.findByTestId("cloud-fleet-summary");
846
+ }
847
+
848
+ test("every tile carries exactly one (i)", async () => {
849
+ await renderStrip();
850
+
851
+ const tiles: Array<HTMLElement> = screen.getAllByTestId(
852
+ "cloud-fleet-summary-tile",
853
+ );
854
+
855
+ expect(tiles).toHaveLength(FLEET_TILES.length);
856
+
857
+ tiles.forEach((tile: HTMLElement, index: number): void => {
858
+ const title: string = FLEET_TILES[index]![0];
859
+
860
+ expect(
861
+ within(tile).getAllByRole("button", { name: /^About / }),
862
+ ).toHaveLength(1);
863
+ expect(
864
+ within(tile).getByRole("button", { name: `About ${title}` }),
865
+ ).toBeInTheDocument();
866
+ });
867
+ });
868
+
869
+ test.each(FLEET_TILES)(
870
+ "the %s tile explains itself on hover",
871
+ async (title: string, key: CloudFleetMetric) => {
872
+ await renderStrip();
873
+
874
+ await hover(screen.getByRole("button", { name: `About ${title}` }));
875
+
876
+ expect(screen.getByRole("tooltip")).toHaveTextContent(F[key]);
877
+ },
878
+ );
879
+
880
+ test("the tooltip text is not rendered until someone asks for it", async () => {
881
+ await renderStrip();
882
+
883
+ for (const [, key] of FLEET_TILES) {
884
+ expect(screen.queryByText(F[key])).not.toBeInTheDocument();
885
+ }
886
+ });
887
+ });