@oneuptime/common 14.0.5 → 14.0.7

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 (192) hide show
  1. package/Models/DatabaseModels/InventoryItem.ts +23 -12
  2. package/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.ts +217 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  4. package/Server/Services/AlertService.ts +32 -36
  5. package/Server/Services/IncidentAlertService.ts +609 -2
  6. package/Server/Services/IncidentService.ts +134 -44
  7. package/Server/Services/InventoryItemService.ts +38 -0
  8. package/Server/Services/IoTDeviceCredentialService.ts +13 -4
  9. package/Server/Services/MetricService.ts +1 -1
  10. package/Server/Services/ScheduledMaintenanceService.ts +50 -46
  11. package/Server/Services/UserNotificationRuleService.ts +55 -53
  12. package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
  13. package/Server/Types/Database/QueryHelper.ts +54 -1
  14. package/Server/Utils/AI/Toolbox/MetricTools.ts +13 -2
  15. package/Server/Utils/AI/Toolbox/ResourceTools.ts +12 -1
  16. package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
  17. package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
  18. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +1 -1
  19. package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
  20. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +33 -0
  21. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +4 -3
  22. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +3 -1
  23. package/Server/Utils/Response.ts +31 -0
  24. package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
  25. package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
  26. package/Server/Utils/Topology/TopologyRequest.ts +463 -0
  27. package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
  28. package/Server/Utils/Topology/TopologySql.ts +1024 -0
  29. package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
  30. package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
  31. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
  32. package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
  33. package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
  34. package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
  35. package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
  36. package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
  37. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
  38. package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
  39. package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
  40. package/Tests/App/Dashboard/EventOverviewPages.test.tsx +292 -1
  41. package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
  42. package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
  43. package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
  44. package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
  45. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
  46. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
  47. package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
  48. package/Tests/App/Dashboard/NetworkTopologyExplorerSearch.test.tsx +1267 -0
  49. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +4 -1
  50. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +127 -0
  51. package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
  52. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
  53. package/Tests/App/Dashboard/SiteSearchBox.test.tsx +2110 -0
  54. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +168 -8
  55. package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
  56. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
  57. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
  58. package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
  59. package/Tests/Server/Services/DomainService.test.ts +525 -0
  60. package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
  61. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
  62. package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
  63. package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
  64. package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
  65. package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
  66. package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
  67. package/Tests/Server/Services/MetricServiceAggregate.test.ts +12 -0
  68. package/Tests/Server/Services/SavedViewDefaults.test.ts +325 -0
  69. package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
  70. package/Tests/Server/Services/StatusPageCertificateService.test.ts +169 -0
  71. package/Tests/Server/Services/TeamComplianceSettingService.test.ts +191 -0
  72. package/Tests/Server/Types/Database/QueryHelperSearchAllWords.test.ts +651 -0
  73. package/Tests/Server/Utils/AI/AlertWriteTools.test.ts +256 -0
  74. package/Tests/Server/Utils/AI/IncidentWriteTools.test.ts +482 -0
  75. package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
  76. package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
  77. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +54 -0
  78. package/Tests/Server/Utils/Monitor/MetricDecimalThreshold.test.ts +417 -0
  79. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +96 -0
  80. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
  81. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
  82. package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
  83. package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
  84. package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
  85. package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
  86. package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
  87. package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
  88. package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
  89. package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
  90. package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
  91. package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
  92. package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
  93. package/Tests/Types/BaseDatabase/AggregationType.test.ts +5 -1
  94. package/Tests/Types/Dashboard/KubernetesDashboardTemplate.test.ts +184 -0
  95. package/Tests/Types/Dashboard/KubernetesDashboardTemplates.test.ts +107 -0
  96. package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +8 -14
  97. package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +4 -3
  98. package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +27 -6
  99. package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
  100. package/Tests/UI/Rum/ChunkLoader.test.ts +76 -0
  101. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +53 -0
  102. package/Tests/UI/Rum/ReplaySignals.test.ts +54 -0
  103. package/Tests/Utils/Dashboard/KubernetesClusterCpuTileRepair.test.ts +370 -0
  104. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
  105. package/Tests/Utils/Rules/MonitorTypeRuleCriteria.test.ts +181 -0
  106. package/Types/BaseDatabase/AggregationType.ts +7 -0
  107. package/Types/Dashboard/DashboardTemplates.ts +20 -7
  108. package/Types/Incident/IncidentAlertLink.ts +19 -0
  109. package/Types/Rum/SessionReplayCustomEvents.ts +29 -3
  110. package/Types/Rum/WebVitals.ts +13 -0
  111. package/Types/Telemetry/InventoryLiveness.ts +26 -0
  112. package/Types/Topology/TopologyApi.ts +428 -0
  113. package/Types/Topology/TopologyTypeRules.ts +191 -0
  114. package/UI/Components/Input/Input.tsx +11 -0
  115. package/Utils/Dashboard/KubernetesClusterCpuTileRepair.ts +147 -0
  116. package/Utils/Incident/IncidentFromAlerts.ts +78 -0
  117. package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
  118. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  119. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
  120. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
  121. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  122. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  123. package/build/dist/Server/Services/AlertService.js +21 -33
  124. package/build/dist/Server/Services/AlertService.js.map +1 -1
  125. package/build/dist/Server/Services/IncidentAlertService.js +457 -3
  126. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
  127. package/build/dist/Server/Services/IncidentService.js +96 -42
  128. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  129. package/build/dist/Server/Services/InventoryItemService.js +31 -0
  130. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  131. package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
  132. package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
  133. package/build/dist/Server/Services/MetricService.js +1 -1
  134. package/build/dist/Server/Services/ScheduledMaintenanceService.js +37 -40
  135. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  136. package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
  137. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  138. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
  139. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
  140. package/build/dist/Server/Types/Database/QueryHelper.js +47 -1
  141. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  142. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js +13 -2
  143. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js.map +1 -1
  144. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +12 -1
  145. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
  146. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
  147. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
  148. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
  149. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
  150. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +1 -1
  151. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
  152. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
  153. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +31 -0
  154. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  155. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
  156. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  157. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1 -1
  158. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  159. package/build/dist/Server/Utils/Response.js +30 -0
  160. package/build/dist/Server/Utils/Response.js.map +1 -1
  161. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
  162. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
  163. package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
  164. package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
  165. package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
  166. package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
  167. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
  168. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
  169. package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
  170. package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
  171. package/build/dist/Types/BaseDatabase/AggregationType.js +7 -0
  172. package/build/dist/Types/BaseDatabase/AggregationType.js.map +1 -1
  173. package/build/dist/Types/Dashboard/DashboardTemplates.js +20 -7
  174. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  175. package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
  176. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
  177. package/build/dist/Types/Rum/SessionReplayCustomEvents.js.map +1 -1
  178. package/build/dist/Types/Rum/WebVitals.js +12 -0
  179. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  180. package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
  181. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
  182. package/build/dist/Types/Topology/TopologyApi.js +55 -0
  183. package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
  184. package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
  185. package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
  186. package/build/dist/UI/Components/Input/Input.js +1 -1
  187. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  188. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js +110 -0
  189. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js.map +1 -0
  190. package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
  191. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
  192. package/package.json +1 -1
@@ -0,0 +1,669 @@
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
+ render,
14
+ RenderResult,
15
+ screen,
16
+ } from "@testing-library/react";
17
+ import React, { ReactElement } from "react";
18
+ import getJestMockFunction, { MockFunction } from "../../MockType";
19
+
20
+ const getItemMock: MockFunction = getJestMockFunction();
21
+
22
+ /*
23
+ * ErrorMessage runs its text through i18next. No instance is set up in these
24
+ * tests, so hand back the source string and keep the run free of the
25
+ * missing-instance warning.
26
+ */
27
+ jest.mock("react-i18next", () => {
28
+ return {
29
+ useTranslation: () => {
30
+ return {
31
+ t: (key: string, options?: { defaultValue?: string }): string => {
32
+ return options?.defaultValue ?? key;
33
+ },
34
+ };
35
+ },
36
+ };
37
+ });
38
+
39
+ /*
40
+ * The arrow wrapper is load bearing: jest.mock is hoisted above the compiled
41
+ * requires, so getItemMock is still unassigned when the factory runs.
42
+ * Dereferencing it lazily, at call time, is what makes this work.
43
+ */
44
+ jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
45
+ return {
46
+ __esModule: true,
47
+ default: {
48
+ getItem: (...args: Array<unknown>) => {
49
+ return getItemMock(...args);
50
+ },
51
+ },
52
+ };
53
+ });
54
+
55
+ import FetchAlertState from "../../../../App/FeatureSet/Dashboard/src/Components/AlertState/FetchAlertState";
56
+ import FetchIncidentState from "../../../../App/FeatureSet/Dashboard/src/Components/IncidentState/FetchIncidentState";
57
+ import FetchAlertSeverity from "../../../../App/FeatureSet/Dashboard/src/Components/AlertSeverity/FetchAlertSeverity";
58
+ import AlertSeverity from "../../../Models/DatabaseModels/AlertSeverity";
59
+ import AlertState from "../../../Models/DatabaseModels/AlertState";
60
+ import IncidentState from "../../../Models/DatabaseModels/IncidentState";
61
+ import HTTPErrorResponse from "../../../Types/API/HTTPErrorResponse";
62
+ import Color from "../../../Types/Color";
63
+ import ObjectID from "../../../Types/ObjectID";
64
+
65
+ /*
66
+ * A create wizard's form holds only the id of the state or severity picked,
67
+ * so the review step used to print a fixed sentence ("Initial state will be
68
+ * set to selected state") that named nothing. The review step now hands the
69
+ * id to one of these three components, which look the model up and draw the
70
+ * same colored pill the event pages use.
71
+ *
72
+ * The three are the same component over three models, so every behaviour here
73
+ * runs once per model. ModelAPI is the only fake: the pill, the loader and
74
+ * the error message are the real ones a user sees.
75
+ */
76
+
77
+ type PickedModel = AlertState | IncidentState | AlertSeverity;
78
+
79
+ type PickedModelType =
80
+ | typeof AlertState
81
+ | typeof IncidentState
82
+ | typeof AlertSeverity;
83
+
84
+ interface PickedChoice {
85
+ id: string;
86
+ name: string;
87
+ color: string;
88
+ }
89
+
90
+ interface FetchCase {
91
+ componentName: string;
92
+ modelType: PickedModelType;
93
+ notFoundSentence: string;
94
+ first: PickedChoice;
95
+ second: PickedChoice;
96
+ buildModel: (
97
+ id: string,
98
+ name: string | undefined,
99
+ color: string | undefined,
100
+ ) => PickedModel;
101
+ renderFor: (id: ObjectID) => ReactElement;
102
+ }
103
+
104
+ /*
105
+ * The models share name/color columns but not a base type that declares
106
+ * them, so each case builds its own. Leaving name or color undefined models
107
+ * a row that has none.
108
+ */
109
+ type ApplyNameAndColorFunction = (
110
+ model: PickedModel,
111
+ id: string,
112
+ name: string | undefined,
113
+ color: string | undefined,
114
+ ) => PickedModel;
115
+
116
+ const applyNameAndColor: ApplyNameAndColorFunction = (
117
+ model: PickedModel,
118
+ id: string,
119
+ name: string | undefined,
120
+ color: string | undefined,
121
+ ): PickedModel => {
122
+ model._id = id;
123
+
124
+ if (name !== undefined) {
125
+ model.name = name;
126
+ }
127
+
128
+ if (color !== undefined) {
129
+ model.color = new Color(color);
130
+ }
131
+
132
+ return model;
133
+ };
134
+
135
+ const FETCH_CASES: Array<FetchCase> = [
136
+ {
137
+ componentName: "FetchAlertState",
138
+ modelType: AlertState,
139
+ notFoundSentence: "The selected alert state could not be found.",
140
+ first: {
141
+ id: "0193c0de-1111-4aaa-8bbb-000000000001",
142
+ name: "Acknowledged",
143
+ color: "#f59e0b",
144
+ },
145
+ second: {
146
+ id: "0193c0de-1111-4aaa-8bbb-000000000002",
147
+ name: "Resolved",
148
+ color: "#10b981",
149
+ },
150
+ buildModel: (
151
+ id: string,
152
+ name: string | undefined,
153
+ color: string | undefined,
154
+ ): PickedModel => {
155
+ return applyNameAndColor(new AlertState(), id, name, color);
156
+ },
157
+ renderFor: (id: ObjectID): ReactElement => {
158
+ return <FetchAlertState alertStateId={id} />;
159
+ },
160
+ },
161
+ {
162
+ componentName: "FetchIncidentState",
163
+ modelType: IncidentState,
164
+ notFoundSentence: "The selected incident state could not be found.",
165
+ first: {
166
+ id: "0193c0de-2222-4aaa-8bbb-000000000001",
167
+ name: "Investigating",
168
+ color: "#ef4444",
169
+ },
170
+ second: {
171
+ id: "0193c0de-2222-4aaa-8bbb-000000000002",
172
+ name: "Monitoring",
173
+ color: "#3b82f6",
174
+ },
175
+ buildModel: (
176
+ id: string,
177
+ name: string | undefined,
178
+ color: string | undefined,
179
+ ): PickedModel => {
180
+ return applyNameAndColor(new IncidentState(), id, name, color);
181
+ },
182
+ renderFor: (id: ObjectID): ReactElement => {
183
+ return <FetchIncidentState incidentStateId={id} />;
184
+ },
185
+ },
186
+ {
187
+ componentName: "FetchAlertSeverity",
188
+ modelType: AlertSeverity,
189
+ notFoundSentence: "The selected alert severity could not be found.",
190
+ first: {
191
+ id: "0193c0de-3333-4aaa-8bbb-000000000001",
192
+ name: "Critical",
193
+ color: "#dc2626",
194
+ },
195
+ second: {
196
+ id: "0193c0de-3333-4aaa-8bbb-000000000002",
197
+ name: "Minor",
198
+ color: "#eab308",
199
+ },
200
+ buildModel: (
201
+ id: string,
202
+ name: string | undefined,
203
+ color: string | undefined,
204
+ ): PickedModel => {
205
+ return applyNameAndColor(new AlertSeverity(), id, name, color);
206
+ },
207
+ renderFor: (id: ObjectID): ReactElement => {
208
+ return <FetchAlertSeverity alertSeverityId={id} />;
209
+ },
210
+ },
211
+ ];
212
+
213
+ /*
214
+ * A promise the test settles by hand, so it decides which lookup answers
215
+ * first - that ordering is the whole point of the stale-answer tests.
216
+ */
217
+ interface Deferred<T> {
218
+ promise: Promise<T>;
219
+ resolve: (value: T) => void;
220
+ reject: (reason: unknown) => void;
221
+ }
222
+
223
+ function createDeferred<T>(): Deferred<T> {
224
+ let resolve: (value: T) => void = (): void => {};
225
+ let reject: (reason: unknown) => void = (): void => {};
226
+
227
+ const promise: Promise<T> = new Promise<T>(
228
+ (
229
+ promiseResolve: (value: T) => void,
230
+ promiseReject: (reason: unknown) => void,
231
+ ): void => {
232
+ resolve = promiseResolve;
233
+ reject = promiseReject;
234
+ },
235
+ );
236
+
237
+ return { promise, resolve, reject };
238
+ }
239
+
240
+ interface GetItemRequest {
241
+ modelType: PickedModelType;
242
+ id: ObjectID;
243
+ select: Record<string, unknown>;
244
+ }
245
+
246
+ function getItemRequest(callIndex: number): GetItemRequest {
247
+ return getItemMock.mock.calls[callIndex]![0] as GetItemRequest;
248
+ }
249
+
250
+ /*
251
+ * AlertStateElement draws a solid pill whose background is the color;
252
+ * IncidentStateElement and AlertSeverityElement draw the minimal pill, where
253
+ * the color is a dot beside the name. Either way the name sits inside a
254
+ * rounded pill, and the colored part is the dot when there is one.
255
+ */
256
+ function getPillColorElement(name: string): HTMLElement {
257
+ const nameElement: HTMLElement = screen.getByText(name);
258
+ const pill: HTMLElement | null = nameElement.closest(".rounded-full");
259
+
260
+ expect(pill).not.toBeNull();
261
+
262
+ const dot: HTMLElement | null = pill!.querySelector('[aria-hidden="true"]');
263
+
264
+ return dot || pill!;
265
+ }
266
+
267
+ /*
268
+ * Asserting on the DOM after each step can miss a name that was painted and
269
+ * then replaced inside a single act(). This keeps every text the container
270
+ * ever held - added nodes and the old value of every edited text node - so a
271
+ * test can say a name never reached the screen, not just that it is gone now.
272
+ */
273
+ interface TextHistory {
274
+ everShown: (text: string) => boolean;
275
+ stop: () => void;
276
+ }
277
+
278
+ function recordTextHistory(root: HTMLElement): TextHistory {
279
+ const seen: Array<string> = [root.textContent || ""];
280
+
281
+ const collect: (records: Array<MutationRecord>) => void = (
282
+ records: Array<MutationRecord>,
283
+ ): void => {
284
+ for (const record of records) {
285
+ if (record.oldValue) {
286
+ seen.push(record.oldValue);
287
+ }
288
+
289
+ record.addedNodes.forEach((node: Node): void => {
290
+ seen.push(node.textContent || "");
291
+ });
292
+ }
293
+
294
+ seen.push(root.textContent || "");
295
+ };
296
+
297
+ const observer: MutationObserver = new MutationObserver(collect);
298
+
299
+ observer.observe(root, {
300
+ childList: true,
301
+ subtree: true,
302
+ characterData: true,
303
+ characterDataOldValue: true,
304
+ });
305
+
306
+ return {
307
+ everShown: (text: string): boolean => {
308
+ collect(observer.takeRecords());
309
+ return seen.some((value: string): boolean => {
310
+ return value.includes(text);
311
+ });
312
+ },
313
+ stop: (): void => {
314
+ observer.disconnect();
315
+ },
316
+ };
317
+ }
318
+
319
+ beforeEach(() => {
320
+ getItemMock.mockReset();
321
+ });
322
+
323
+ afterEach(() => {
324
+ cleanup();
325
+ jest.restoreAllMocks();
326
+ });
327
+
328
+ describe.each(FETCH_CASES)("$componentName", (fetchCase: FetchCase) => {
329
+ const firstModel: () => PickedModel = (): PickedModel => {
330
+ return fetchCase.buildModel(
331
+ fetchCase.first.id,
332
+ fetchCase.first.name,
333
+ fetchCase.first.color,
334
+ );
335
+ };
336
+
337
+ const secondModel: () => PickedModel = (): PickedModel => {
338
+ return fetchCase.buildModel(
339
+ fetchCase.second.id,
340
+ fetchCase.second.name,
341
+ fetchCase.second.color,
342
+ );
343
+ };
344
+
345
+ test("shows a loader while the lookup is out, then the picked name in a pill of its color", async () => {
346
+ const lookup: Deferred<PickedModel | null> =
347
+ createDeferred<PickedModel | null>();
348
+ getItemMock.mockReturnValueOnce(lookup.promise);
349
+
350
+ render(fetchCase.renderFor(new ObjectID(fetchCase.first.id)));
351
+
352
+ expect(screen.getByTestId("component-loader")).toBeInTheDocument();
353
+ expect(screen.queryByText(fetchCase.first.name)).toBeNull();
354
+
355
+ await act(async () => {
356
+ lookup.resolve(firstModel());
357
+ });
358
+
359
+ expect(screen.queryByTestId("component-loader")).toBeNull();
360
+ expect(screen.getByText(fetchCase.first.name)).toBeInTheDocument();
361
+ expect(getPillColorElement(fetchCase.first.name)).toHaveStyle({
362
+ backgroundColor: fetchCase.first.color,
363
+ });
364
+ });
365
+
366
+ test("looks up exactly the picked id on the right model, asking for its name and color", async () => {
367
+ getItemMock.mockResolvedValueOnce(firstModel());
368
+
369
+ render(fetchCase.renderFor(new ObjectID(fetchCase.first.id)));
370
+
371
+ expect(await screen.findByText(fetchCase.first.name)).toBeInTheDocument();
372
+ expect(getItemMock).toHaveBeenCalledTimes(1);
373
+
374
+ const request: GetItemRequest = getItemRequest(0);
375
+
376
+ expect(request.modelType).toBe(fetchCase.modelType);
377
+ expect(request.id).toBeInstanceOf(ObjectID);
378
+ expect(request.id.toString()).toBe(fetchCase.first.id);
379
+ expect(request.select).toEqual(
380
+ expect.objectContaining({ name: true, color: true }),
381
+ );
382
+ });
383
+
384
+ test.each([
385
+ {
386
+ failure: "a plain error",
387
+ error: new Error("Could not load the picked value."),
388
+ message: "Could not load the picked value.",
389
+ },
390
+ {
391
+ failure: "a 502 from the gateway",
392
+ error: new HTTPErrorResponse(502, { message: "Bad Gateway" }, {}),
393
+ message: "Error connecting to server. Please try again in few minutes.",
394
+ },
395
+ ])(
396
+ "when the lookup fails with $failure it shows the friendly message and no pill",
397
+ async ({ error, message }: { error: unknown; message: string }) => {
398
+ getItemMock.mockRejectedValueOnce(error);
399
+
400
+ render(fetchCase.renderFor(new ObjectID(fetchCase.first.id)));
401
+
402
+ expect(await screen.findByText(message)).toBeInTheDocument();
403
+ expect(screen.queryByTestId("component-loader")).toBeNull();
404
+ expect(screen.queryByTestId("pill")).toBeNull();
405
+ expect(document.querySelector(".rounded-full")).toBeNull();
406
+ expect(screen.queryByText(fetchCase.notFoundSentence)).toBeNull();
407
+ },
408
+ );
409
+
410
+ /*
411
+ * The real ModelAPI.getItem never resolves null for a missing row: the
412
+ * server answers {} and the client turns that into a model with no id.
413
+ * Read as a row, that empty model would draw a black "Unknown" pill for a
414
+ * state or severity that was deleted after it was picked.
415
+ */
416
+ test.each([
417
+ {
418
+ answer: "an empty model (what the API sends for a missing id)",
419
+ build: (): PickedModel | null => {
420
+ return new fetchCase.modelType();
421
+ },
422
+ },
423
+ {
424
+ answer: "null",
425
+ build: (): PickedModel | null => {
426
+ return null;
427
+ },
428
+ },
429
+ ])(
430
+ "says the picked value could not be found when the lookup returns $answer",
431
+ async ({ build }: { build: () => PickedModel | null }) => {
432
+ getItemMock.mockResolvedValueOnce(build());
433
+
434
+ render(fetchCase.renderFor(new ObjectID(fetchCase.first.id)));
435
+
436
+ expect(
437
+ await screen.findByText(fetchCase.notFoundSentence),
438
+ ).toBeInTheDocument();
439
+ expect(screen.queryByTestId("component-loader")).toBeNull();
440
+ expect(document.querySelector(".rounded-full")).toBeNull();
441
+ expect(screen.queryByText("Unknown")).toBeNull();
442
+ },
443
+ );
444
+
445
+ test("a row with no name or color still shows a pill, reading 'Unknown' in black", async () => {
446
+ getItemMock.mockResolvedValueOnce(
447
+ fetchCase.buildModel(fetchCase.first.id, undefined, undefined),
448
+ );
449
+
450
+ render(fetchCase.renderFor(new ObjectID(fetchCase.first.id)));
451
+
452
+ expect(await screen.findByText("Unknown")).toBeInTheDocument();
453
+ expect(getPillColorElement("Unknown")).toHaveStyle({
454
+ backgroundColor: "#000000",
455
+ });
456
+ expect(screen.queryByText(fetchCase.notFoundSentence)).toBeNull();
457
+ });
458
+
459
+ test("picking a different value looks the new one up and shows its name, not the old one", async () => {
460
+ const secondLookup: Deferred<PickedModel | null> =
461
+ createDeferred<PickedModel | null>();
462
+ getItemMock
463
+ .mockResolvedValueOnce(firstModel())
464
+ .mockReturnValueOnce(secondLookup.promise);
465
+
466
+ const view: RenderResult = render(
467
+ fetchCase.renderFor(new ObjectID(fetchCase.first.id)),
468
+ );
469
+
470
+ expect(await screen.findByText(fetchCase.first.name)).toBeInTheDocument();
471
+
472
+ view.rerender(fetchCase.renderFor(new ObjectID(fetchCase.second.id)));
473
+
474
+ /*
475
+ * While the new choice is looked up, the review step must not keep
476
+ * naming the old one.
477
+ */
478
+ expect(screen.getByTestId("component-loader")).toBeInTheDocument();
479
+ expect(screen.queryByText(fetchCase.first.name)).toBeNull();
480
+
481
+ expect(getItemMock).toHaveBeenCalledTimes(2);
482
+ expect(getItemRequest(1).id.toString()).toBe(fetchCase.second.id);
483
+ expect(getItemRequest(1).modelType).toBe(fetchCase.modelType);
484
+
485
+ await act(async () => {
486
+ secondLookup.resolve(secondModel());
487
+ });
488
+
489
+ expect(screen.getByText(fetchCase.second.name)).toBeInTheDocument();
490
+ expect(getPillColorElement(fetchCase.second.name)).toHaveStyle({
491
+ backgroundColor: fetchCase.second.color,
492
+ });
493
+ expect(screen.queryByText(fetchCase.first.name)).toBeNull();
494
+ });
495
+
496
+ /*
497
+ * The create pages build a fresh ObjectID from the form value on every
498
+ * render, so keying the lookup on the object rather than its value would
499
+ * refetch - and flash the loader - each time the review step re-renders.
500
+ */
501
+ test("a re-render that passes the same id as a new ObjectID does not look it up again", async () => {
502
+ getItemMock.mockResolvedValueOnce(firstModel());
503
+
504
+ const view: RenderResult = render(
505
+ fetchCase.renderFor(new ObjectID(fetchCase.first.id)),
506
+ );
507
+
508
+ expect(await screen.findByText(fetchCase.first.name)).toBeInTheDocument();
509
+
510
+ view.rerender(fetchCase.renderFor(new ObjectID(fetchCase.first.id)));
511
+
512
+ expect(getItemMock).toHaveBeenCalledTimes(1);
513
+ expect(screen.queryByTestId("component-loader")).toBeNull();
514
+ expect(screen.getByText(fetchCase.first.name)).toBeInTheDocument();
515
+ });
516
+
517
+ test("an error for one value does not linger once a different value is picked", async () => {
518
+ getItemMock
519
+ .mockRejectedValueOnce(new Error("Could not load the picked value."))
520
+ .mockResolvedValueOnce(secondModel());
521
+
522
+ const view: RenderResult = render(
523
+ fetchCase.renderFor(new ObjectID(fetchCase.first.id)),
524
+ );
525
+
526
+ expect(
527
+ await screen.findByText("Could not load the picked value."),
528
+ ).toBeInTheDocument();
529
+
530
+ view.rerender(fetchCase.renderFor(new ObjectID(fetchCase.second.id)));
531
+
532
+ expect(await screen.findByText(fetchCase.second.name)).toBeInTheDocument();
533
+ expect(screen.queryByText("Could not load the picked value.")).toBeNull();
534
+ });
535
+
536
+ test("an answer for the old value that arrives after the new value's answer never replaces it", async () => {
537
+ const firstLookup: Deferred<PickedModel | null> =
538
+ createDeferred<PickedModel | null>();
539
+ const secondLookup: Deferred<PickedModel | null> =
540
+ createDeferred<PickedModel | null>();
541
+ getItemMock
542
+ .mockReturnValueOnce(firstLookup.promise)
543
+ .mockReturnValueOnce(secondLookup.promise);
544
+
545
+ const view: RenderResult = render(
546
+ fetchCase.renderFor(new ObjectID(fetchCase.first.id)),
547
+ );
548
+ const history: TextHistory = recordTextHistory(view.container);
549
+
550
+ view.rerender(fetchCase.renderFor(new ObjectID(fetchCase.second.id)));
551
+
552
+ await act(async () => {
553
+ secondLookup.resolve(secondModel());
554
+ });
555
+
556
+ expect(screen.getByText(fetchCase.second.name)).toBeInTheDocument();
557
+
558
+ await act(async () => {
559
+ firstLookup.resolve(firstModel());
560
+ });
561
+
562
+ expect(screen.getByText(fetchCase.second.name)).toBeInTheDocument();
563
+ expect(getPillColorElement(fetchCase.second.name)).toHaveStyle({
564
+ backgroundColor: fetchCase.second.color,
565
+ });
566
+ expect(history.everShown(fetchCase.first.name)).toBe(false);
567
+
568
+ history.stop();
569
+ });
570
+
571
+ test("an answer for the old value that arrives while the new value is loading is ignored", async () => {
572
+ const firstLookup: Deferred<PickedModel | null> =
573
+ createDeferred<PickedModel | null>();
574
+ const secondLookup: Deferred<PickedModel | null> =
575
+ createDeferred<PickedModel | null>();
576
+ getItemMock
577
+ .mockReturnValueOnce(firstLookup.promise)
578
+ .mockReturnValueOnce(secondLookup.promise);
579
+
580
+ const view: RenderResult = render(
581
+ fetchCase.renderFor(new ObjectID(fetchCase.first.id)),
582
+ );
583
+ const history: TextHistory = recordTextHistory(view.container);
584
+
585
+ view.rerender(fetchCase.renderFor(new ObjectID(fetchCase.second.id)));
586
+
587
+ await act(async () => {
588
+ firstLookup.resolve(firstModel());
589
+ });
590
+
591
+ // Still waiting on the value that is actually picked.
592
+ expect(screen.getByTestId("component-loader")).toBeInTheDocument();
593
+
594
+ await act(async () => {
595
+ secondLookup.resolve(secondModel());
596
+ });
597
+
598
+ expect(screen.getByText(fetchCase.second.name)).toBeInTheDocument();
599
+ expect(history.everShown(fetchCase.first.name)).toBe(false);
600
+
601
+ history.stop();
602
+ });
603
+
604
+ test("a failure for the old value that arrives after the new value's answer is ignored too", async () => {
605
+ const firstLookup: Deferred<PickedModel | null> =
606
+ createDeferred<PickedModel | null>();
607
+ getItemMock
608
+ .mockReturnValueOnce(firstLookup.promise)
609
+ .mockResolvedValueOnce(secondModel());
610
+
611
+ const view: RenderResult = render(
612
+ fetchCase.renderFor(new ObjectID(fetchCase.first.id)),
613
+ );
614
+
615
+ view.rerender(fetchCase.renderFor(new ObjectID(fetchCase.second.id)));
616
+
617
+ expect(await screen.findByText(fetchCase.second.name)).toBeInTheDocument();
618
+
619
+ await act(async () => {
620
+ firstLookup.reject(new Error("Could not load the picked value."));
621
+ });
622
+
623
+ expect(screen.getByText(fetchCase.second.name)).toBeInTheDocument();
624
+ expect(screen.queryByText("Could not load the picked value.")).toBeNull();
625
+ });
626
+
627
+ test.each([
628
+ { outcome: "an answer", settle: "resolve" as const },
629
+ { outcome: "a failure", settle: "reject" as const },
630
+ ])(
631
+ "leaving the review step before $outcome arrives logs nothing and throws nothing",
632
+ async ({ settle }: { settle: "resolve" | "reject" }) => {
633
+ const lookup: Deferred<PickedModel | null> =
634
+ createDeferred<PickedModel | null>();
635
+ getItemMock.mockReturnValueOnce(lookup.promise);
636
+
637
+ const view: RenderResult = render(
638
+ fetchCase.renderFor(new ObjectID(fetchCase.first.id)),
639
+ );
640
+
641
+ view.unmount();
642
+
643
+ /*
644
+ * Spy only from here: the question is what the late answer does, and
645
+ * render() itself can print a once-per-run test-utils deprecation
646
+ * warning that has nothing to do with these components.
647
+ */
648
+ const consoleErrorSpy: ReturnType<typeof jest.spyOn> = jest
649
+ .spyOn(console, "error")
650
+ .mockImplementation((): void => {});
651
+ const consoleWarnSpy: ReturnType<typeof jest.spyOn> = jest
652
+ .spyOn(console, "warn")
653
+ .mockImplementation((): void => {});
654
+
655
+ await act(async () => {
656
+ if (settle === "resolve") {
657
+ lookup.resolve(firstModel());
658
+ } else {
659
+ lookup.reject(new Error("Could not load the picked value."));
660
+ }
661
+ });
662
+
663
+ expect(getItemMock).toHaveBeenCalledTimes(1);
664
+ expect(consoleErrorSpy).not.toHaveBeenCalled();
665
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
666
+ expect(document.body.textContent).toBe("");
667
+ },
668
+ );
669
+ });