@oneuptime/common 14.0.6 → 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.
- package/Models/DatabaseModels/InventoryItem.ts +23 -12
- package/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.ts +217 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Services/AlertService.ts +32 -36
- package/Server/Services/IncidentAlertService.ts +609 -2
- package/Server/Services/IncidentService.ts +134 -44
- package/Server/Services/InventoryItemService.ts +38 -0
- package/Server/Services/IoTDeviceCredentialService.ts +13 -4
- package/Server/Services/ScheduledMaintenanceService.ts +50 -46
- package/Server/Services/UserNotificationRuleService.ts +55 -53
- package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
- package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
- package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
- package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
- package/Server/Utils/Response.ts +31 -0
- package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
- package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
- package/Server/Utils/Topology/TopologyRequest.ts +463 -0
- package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
- package/Server/Utils/Topology/TopologySql.ts +1024 -0
- package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
- package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
- package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
- package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
- package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
- package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
- package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
- package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
- package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
- package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
- package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
- package/Tests/App/Dashboard/EventOverviewPages.test.tsx +292 -1
- package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
- package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
- package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
- package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
- package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
- package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
- package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
- package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
- package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
- package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
- package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
- package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
- package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
- package/Tests/Server/Services/DomainService.test.ts +525 -0
- package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
- package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
- package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
- package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
- package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
- package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
- package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
- package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
- package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
- package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
- package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
- package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
- package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
- package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
- package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
- package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
- package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
- package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
- package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
- package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
- package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
- package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
- package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
- package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
- package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
- package/Types/Incident/IncidentAlertLink.ts +19 -0
- package/Types/Telemetry/InventoryLiveness.ts +26 -0
- package/Types/Topology/TopologyApi.ts +428 -0
- package/Types/Topology/TopologyTypeRules.ts +191 -0
- package/Utils/Incident/IncidentFromAlerts.ts +78 -0
- package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
- package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/AlertService.js +21 -33
- package/build/dist/Server/Services/AlertService.js.map +1 -1
- package/build/dist/Server/Services/IncidentAlertService.js +457 -3
- package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
- package/build/dist/Server/Services/IncidentService.js +96 -42
- package/build/dist/Server/Services/IncidentService.js.map +1 -1
- package/build/dist/Server/Services/InventoryItemService.js +31 -0
- package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
- package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
- package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
- package/build/dist/Server/Services/ScheduledMaintenanceService.js +37 -40
- package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
- package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
- package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
- package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
- package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
- package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
- package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
- package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
- package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
- package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
- package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
- package/build/dist/Server/Utils/Response.js +30 -0
- package/build/dist/Server/Utils/Response.js.map +1 -1
- package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
- package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
- package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
- package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
- package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
- package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
- package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
- package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
- package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
- package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
- package/build/dist/Types/Topology/TopologyApi.js +55 -0
- package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
- package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
- package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
- package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
- package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
- 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
|
+
});
|