@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,583 @@
|
|
|
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 { act, cleanup, render, screen } from "@testing-library/react";
|
|
11
|
+
import React from "react";
|
|
12
|
+
import type { SpyInstance } from "jest-mock";
|
|
13
|
+
import getJestMockFunction, { MockFunction } from "../../MockType";
|
|
14
|
+
import HTTPResponse from "../../../Types/API/HTTPResponse";
|
|
15
|
+
import URL from "../../../Types/API/URL";
|
|
16
|
+
import InBetween from "../../../Types/BaseDatabase/InBetween";
|
|
17
|
+
import Dictionary from "../../../Types/Dictionary";
|
|
18
|
+
import { JSONObject } from "../../../Types/JSON";
|
|
19
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
20
|
+
import EntityType from "../../../Types/Telemetry/EntityType";
|
|
21
|
+
import RangeStartAndEndDateTime from "../../../Types/Time/RangeStartAndEndDateTime";
|
|
22
|
+
import TimeRange from "../../../Types/Time/TimeRange";
|
|
23
|
+
import {
|
|
24
|
+
TOPOLOGY_API_FORMAT_VERSION,
|
|
25
|
+
TopologyApiPath,
|
|
26
|
+
} from "../../../Types/Topology/TopologyApi";
|
|
27
|
+
import useTopologyData, {
|
|
28
|
+
TopologyData,
|
|
29
|
+
TopologyView,
|
|
30
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/UseTopologyData";
|
|
31
|
+
import { TopologyEntity } from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/TopologyData";
|
|
32
|
+
|
|
33
|
+
/*
|
|
34
|
+
* useTopologyData's lifecycle edges that TopologyDataLoading does not reach:
|
|
35
|
+
*
|
|
36
|
+
* - React 18 StrictMode mounts, unmounts and re-mounts every effect in
|
|
37
|
+
* development. The hook must come out of that with exactly ONE live
|
|
38
|
+
* request per tab (the discarded one aborted, its answer ignored), not
|
|
39
|
+
* two loads racing to write the same tab.
|
|
40
|
+
* - Unmounting with BOTH tabs in flight aborts both, and nothing that
|
|
41
|
+
* arrives afterwards writes state or logs.
|
|
42
|
+
* - A project change mid-flight — including one that returns to the first
|
|
43
|
+
* project, or loses the project altogether — never lets one project's
|
|
44
|
+
* payload appear under another, and aborts the background tab too.
|
|
45
|
+
*
|
|
46
|
+
* Same seam as TopologyDataLoading: API.post answered by a fake server that
|
|
47
|
+
* records every request and answers when the test says so.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
const postMock: MockFunction = getJestMockFunction();
|
|
51
|
+
const getProjectIdMock: MockFunction = getJestMockFunction();
|
|
52
|
+
|
|
53
|
+
jest.mock("../../../UI/Utils/API/API", () => {
|
|
54
|
+
return {
|
|
55
|
+
__esModule: true,
|
|
56
|
+
default: {
|
|
57
|
+
post: (...args: Array<unknown>) => {
|
|
58
|
+
return postMock(...args);
|
|
59
|
+
},
|
|
60
|
+
getFriendlyMessage: (error: unknown): string => {
|
|
61
|
+
if (error && typeof error === "object" && "message" in error) {
|
|
62
|
+
return String((error as { message: unknown }).message);
|
|
63
|
+
}
|
|
64
|
+
return "Unable to load topology";
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
|
|
71
|
+
return {
|
|
72
|
+
__esModule: true,
|
|
73
|
+
default: {
|
|
74
|
+
getCommonHeaders: (): Dictionary<string> => {
|
|
75
|
+
return { tenantid: String(getProjectIdMock()) };
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
jest.mock("../../../UI/Utils/Project", () => {
|
|
82
|
+
return {
|
|
83
|
+
__esModule: true,
|
|
84
|
+
default: {
|
|
85
|
+
getCurrentProjectId: () => {
|
|
86
|
+
return getProjectIdMock();
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const PROJECT_ONE: ObjectID = new ObjectID(
|
|
93
|
+
"11111111-1111-4111-8111-111111111111",
|
|
94
|
+
);
|
|
95
|
+
const PROJECT_TWO: ObjectID = new ObjectID(
|
|
96
|
+
"22222222-2222-4222-8222-222222222222",
|
|
97
|
+
);
|
|
98
|
+
const RANGE: RangeStartAndEndDateTime = {
|
|
99
|
+
range: TimeRange.CUSTOM,
|
|
100
|
+
startAndEndDate: new InBetween<Date>(
|
|
101
|
+
new Date("2026-09-01T09:15:00.000Z"),
|
|
102
|
+
new Date("2026-09-01T10:15:00.000Z"),
|
|
103
|
+
),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
interface PostOptions {
|
|
107
|
+
url: URL;
|
|
108
|
+
data: JSONObject;
|
|
109
|
+
headers: Dictionary<string>;
|
|
110
|
+
options?: { signal?: AbortSignal };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface RecordedRequest {
|
|
114
|
+
path: string;
|
|
115
|
+
rangeStart: string;
|
|
116
|
+
/* The `fresh` flag as sent; undefined when the body had none. */
|
|
117
|
+
fresh: unknown;
|
|
118
|
+
tenant: string;
|
|
119
|
+
signal: AbortSignal | undefined;
|
|
120
|
+
respond: (payload: JSONObject) => void;
|
|
121
|
+
reject: (error: Error) => void;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let requests: Array<RecordedRequest> = [];
|
|
125
|
+
let autoRespond: boolean = false;
|
|
126
|
+
|
|
127
|
+
function pathOf(url: URL): string {
|
|
128
|
+
return url.toString().replace(/^.*\/api(?=\/)/, "");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function requestsTo(path: TopologyApiPath): Array<RecordedRequest> {
|
|
132
|
+
return requests.filter((request: RecordedRequest): boolean => {
|
|
133
|
+
return request.path === path;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function live(list: Array<RecordedRequest>): Array<RecordedRequest> {
|
|
138
|
+
return list.filter((request: RecordedRequest): boolean => {
|
|
139
|
+
return !request.signal?.aborted;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function serviceMapPayload(rangeStart: string, name: string): JSONObject {
|
|
144
|
+
return {
|
|
145
|
+
formatVersion: TOPOLOGY_API_FORMAT_VERSION,
|
|
146
|
+
rangeStart: rangeStart,
|
|
147
|
+
generatedAt: "2026-09-01T10:15:30.000Z",
|
|
148
|
+
entities: [
|
|
149
|
+
{
|
|
150
|
+
key: `service:${name}`,
|
|
151
|
+
type: EntityType.Service,
|
|
152
|
+
name: name,
|
|
153
|
+
source: "discovered",
|
|
154
|
+
lastSeenAt: null,
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
dependencies: [],
|
|
158
|
+
runsOn: [],
|
|
159
|
+
entityTruncation: null,
|
|
160
|
+
dependencyTruncation: null,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function infrastructurePayload(rangeStart: string, name: string): JSONObject {
|
|
165
|
+
return {
|
|
166
|
+
formatVersion: TOPOLOGY_API_FORMAT_VERSION,
|
|
167
|
+
rangeStart: rangeStart,
|
|
168
|
+
generatedAt: "2026-09-01T10:15:30.000Z",
|
|
169
|
+
nodes: [
|
|
170
|
+
{
|
|
171
|
+
key: `host:${name}`,
|
|
172
|
+
type: EntityType.Host,
|
|
173
|
+
name: name,
|
|
174
|
+
source: "discovered",
|
|
175
|
+
lastSeenAt: null,
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
services: [],
|
|
179
|
+
placements: [],
|
|
180
|
+
collections: [],
|
|
181
|
+
totals: { resources: 1, activeResources: 1 },
|
|
182
|
+
truncation: null,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function payloadFor(request: RecordedRequest, name: string): JSONObject {
|
|
187
|
+
return request.path === TopologyApiPath.ServiceMap
|
|
188
|
+
? serviceMapPayload(request.rangeStart, name)
|
|
189
|
+
: infrastructurePayload(request.rangeStart, name);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function installFakeServer(): void {
|
|
193
|
+
postMock.mockImplementation((...args: Array<unknown>) => {
|
|
194
|
+
const options: PostOptions = args[0] as PostOptions;
|
|
195
|
+
return new Promise(
|
|
196
|
+
(
|
|
197
|
+
resolve: (value: HTTPResponse<JSONObject>) => void,
|
|
198
|
+
reject: (error: Error) => void,
|
|
199
|
+
): void => {
|
|
200
|
+
const request: RecordedRequest = {
|
|
201
|
+
path: pathOf(options.url),
|
|
202
|
+
rangeStart: String(options.data["rangeStart"]),
|
|
203
|
+
fresh: options.data["fresh"],
|
|
204
|
+
tenant: String(options.headers["tenantid"]),
|
|
205
|
+
signal: options.options?.signal,
|
|
206
|
+
respond: (payload: JSONObject): void => {
|
|
207
|
+
resolve(new HTTPResponse<JSONObject>(200, payload, {}));
|
|
208
|
+
},
|
|
209
|
+
reject: reject,
|
|
210
|
+
};
|
|
211
|
+
requests.push(request);
|
|
212
|
+
if (autoRespond) {
|
|
213
|
+
request.respond(
|
|
214
|
+
payloadFor(
|
|
215
|
+
request,
|
|
216
|
+
`${request.path === TopologyApiPath.ServiceMap ? "service map" : "infrastructure"} #${requestsTo(request.path as TopologyApiPath).length}`,
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function names(entities: Array<TopologyEntity> | undefined): string {
|
|
226
|
+
return (entities || [])
|
|
227
|
+
.map((entity: TopologyEntity): string => {
|
|
228
|
+
return entity.displayName || "";
|
|
229
|
+
})
|
|
230
|
+
.join(",");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function Probe(props: {
|
|
234
|
+
range: RangeStartAndEndDateTime;
|
|
235
|
+
view: TopologyView;
|
|
236
|
+
}): React.ReactElement {
|
|
237
|
+
const data: TopologyData = useTopologyData(props.range, props.view);
|
|
238
|
+
return (
|
|
239
|
+
<div>
|
|
240
|
+
<span data-testid="sm-status">{data.serviceMap.status}</span>
|
|
241
|
+
<span data-testid="sm-error">{data.serviceMap.error?.message || ""}</span>
|
|
242
|
+
<span data-testid="sm-entities">
|
|
243
|
+
{names(data.serviceMap.data?.entities)}
|
|
244
|
+
</span>
|
|
245
|
+
<span data-testid="infra-status">{data.infrastructure.status}</span>
|
|
246
|
+
<span data-testid="infra-entities">
|
|
247
|
+
{names(data.infrastructure.data?.entities)}
|
|
248
|
+
</span>
|
|
249
|
+
</div>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function StrictProbe(props: { view: TopologyView }): React.ReactElement {
|
|
254
|
+
return (
|
|
255
|
+
<React.StrictMode>
|
|
256
|
+
<Probe range={RANGE} view={props.view} />
|
|
257
|
+
</React.StrictMode>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function text(testId: string): string {
|
|
262
|
+
return screen.getByTestId(testId).textContent || "";
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function flush(): Promise<void> {
|
|
266
|
+
await act(async () => {
|
|
267
|
+
await Promise.resolve();
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let consoleError: SpyInstance<(...data: Array<unknown>) => void>;
|
|
272
|
+
|
|
273
|
+
/*
|
|
274
|
+
* console.error calls other than the testing library's own one-time
|
|
275
|
+
* "ReactDOMTestUtils.act is deprecated" notice, which lands in whichever
|
|
276
|
+
* test first calls act().
|
|
277
|
+
*/
|
|
278
|
+
function unexpectedConsoleErrors(): Array<string> {
|
|
279
|
+
return consoleError.mock.calls
|
|
280
|
+
.map((call: Array<unknown>): string => {
|
|
281
|
+
return call.map(String).join(" ");
|
|
282
|
+
})
|
|
283
|
+
.filter((message: string): boolean => {
|
|
284
|
+
return !message.includes("ReactDOMTestUtils.act");
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
beforeEach(() => {
|
|
289
|
+
requests = [];
|
|
290
|
+
autoRespond = false;
|
|
291
|
+
postMock.mockReset();
|
|
292
|
+
getProjectIdMock.mockReset();
|
|
293
|
+
getProjectIdMock.mockReturnValue(PROJECT_ONE);
|
|
294
|
+
installFakeServer();
|
|
295
|
+
consoleError = jest.spyOn(console, "error");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
afterEach(() => {
|
|
299
|
+
cleanup();
|
|
300
|
+
jest.restoreAllMocks();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe("StrictMode's double mount", () => {
|
|
304
|
+
test("leaves exactly one live request for the active tab, and only its answer lands", async () => {
|
|
305
|
+
render(<StrictProbe view="Service Map" />);
|
|
306
|
+
await flush();
|
|
307
|
+
|
|
308
|
+
const serviceMap: Array<RecordedRequest> = requestsTo(
|
|
309
|
+
TopologyApiPath.ServiceMap,
|
|
310
|
+
);
|
|
311
|
+
/*
|
|
312
|
+
* React 18 replays mount effects under StrictMode in development, so the
|
|
313
|
+
* hook really did start twice: the first load is aborted, the second is
|
|
314
|
+
* the one that counts.
|
|
315
|
+
*/
|
|
316
|
+
expect(serviceMap).toHaveLength(2);
|
|
317
|
+
expect(serviceMap[0]!.signal?.aborted).toBe(true);
|
|
318
|
+
expect(live(serviceMap)).toEqual([serviceMap[1]]);
|
|
319
|
+
expect(serviceMap[1]!.rangeStart).toBe(serviceMap[0]!.rangeStart);
|
|
320
|
+
expect(requestsTo(TopologyApiPath.Infrastructure)).toHaveLength(0);
|
|
321
|
+
/*
|
|
322
|
+
* The replayed mount is not the user's refresh: neither load may make
|
|
323
|
+
* the server skip its response cache.
|
|
324
|
+
*/
|
|
325
|
+
expect(
|
|
326
|
+
serviceMap.map((request: RecordedRequest): unknown => {
|
|
327
|
+
return request.fresh;
|
|
328
|
+
}),
|
|
329
|
+
).toEqual([undefined, undefined]);
|
|
330
|
+
|
|
331
|
+
/* The discarded mount's answer arriving first changes nothing... */
|
|
332
|
+
await act(async () => {
|
|
333
|
+
serviceMap[0]!.respond(
|
|
334
|
+
serviceMapPayload(serviceMap[0]!.rangeStart, "discarded mount"),
|
|
335
|
+
);
|
|
336
|
+
});
|
|
337
|
+
expect(text("sm-status")).toBe("loading");
|
|
338
|
+
expect(text("sm-entities")).toBe("");
|
|
339
|
+
|
|
340
|
+
/* ...and the live one is what the tab shows. */
|
|
341
|
+
await act(async () => {
|
|
342
|
+
serviceMap[1]!.respond(
|
|
343
|
+
serviceMapPayload(serviceMap[1]!.rangeStart, "live mount"),
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
expect(text("sm-status")).toBe("ready");
|
|
347
|
+
expect(text("sm-entities")).toBe("live mount");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("opening and revisiting tabs afterwards still loads each tab once", async () => {
|
|
351
|
+
autoRespond = true;
|
|
352
|
+
const view: ReturnType<typeof render> = render(
|
|
353
|
+
<StrictProbe view="Service Map" />,
|
|
354
|
+
);
|
|
355
|
+
await flush();
|
|
356
|
+
const serviceMapRequests: number = requestsTo(
|
|
357
|
+
TopologyApiPath.ServiceMap,
|
|
358
|
+
).length;
|
|
359
|
+
|
|
360
|
+
view.rerender(<StrictProbe view="Infrastructure" />);
|
|
361
|
+
await flush();
|
|
362
|
+
view.rerender(<StrictProbe view="Service Map" />);
|
|
363
|
+
await flush();
|
|
364
|
+
view.rerender(<StrictProbe view="Infrastructure" />);
|
|
365
|
+
await flush();
|
|
366
|
+
|
|
367
|
+
/* Updates are not replayed: one Infrastructure load, no Service Map reload. */
|
|
368
|
+
expect(requestsTo(TopologyApiPath.Infrastructure)).toHaveLength(1);
|
|
369
|
+
expect(
|
|
370
|
+
requestsTo(TopologyApiPath.Infrastructure)[0]!.fresh,
|
|
371
|
+
).toBeUndefined();
|
|
372
|
+
expect(requestsTo(TopologyApiPath.ServiceMap)).toHaveLength(
|
|
373
|
+
serviceMapRequests,
|
|
374
|
+
);
|
|
375
|
+
expect(text("infra-status")).toBe("ready");
|
|
376
|
+
expect(text("infra-entities")).toBe("infrastructure #1");
|
|
377
|
+
expect(text("sm-status")).toBe("ready");
|
|
378
|
+
/* Both tabs describe the same pinned moment. */
|
|
379
|
+
expect(
|
|
380
|
+
new Set<string>(
|
|
381
|
+
live(requests).map((request: RecordedRequest): string => {
|
|
382
|
+
return request.rangeStart;
|
|
383
|
+
}),
|
|
384
|
+
).size,
|
|
385
|
+
).toBe(1);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("unmounting under StrictMode aborts every request it made", async () => {
|
|
389
|
+
const view: ReturnType<typeof render> = render(
|
|
390
|
+
<StrictProbe view="Service Map" />,
|
|
391
|
+
);
|
|
392
|
+
await flush();
|
|
393
|
+
view.rerender(<StrictProbe view="Infrastructure" />);
|
|
394
|
+
await flush();
|
|
395
|
+
expect(live(requests)).toHaveLength(2);
|
|
396
|
+
|
|
397
|
+
view.unmount();
|
|
398
|
+
|
|
399
|
+
expect(live(requests)).toHaveLength(0);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
describe("unmounting", () => {
|
|
404
|
+
test("aborts both tabs' requests, and nothing that arrives afterwards writes or logs", async () => {
|
|
405
|
+
const view: ReturnType<typeof render> = render(
|
|
406
|
+
<Probe range={RANGE} view="Service Map" />,
|
|
407
|
+
);
|
|
408
|
+
await flush();
|
|
409
|
+
view.rerender(<Probe range={RANGE} view="Infrastructure" />);
|
|
410
|
+
await flush();
|
|
411
|
+
const [serviceMap, infrastructure]: Array<RecordedRequest> = requests;
|
|
412
|
+
expect(requests).toHaveLength(2);
|
|
413
|
+
expect(serviceMap!.signal?.aborted).toBe(false);
|
|
414
|
+
expect(infrastructure!.signal?.aborted).toBe(false);
|
|
415
|
+
|
|
416
|
+
/* Only what happens from here on is under test. */
|
|
417
|
+
consoleError.mockClear();
|
|
418
|
+
view.unmount();
|
|
419
|
+
|
|
420
|
+
expect(serviceMap!.signal?.aborted).toBe(true);
|
|
421
|
+
expect(infrastructure!.signal?.aborted).toBe(true);
|
|
422
|
+
|
|
423
|
+
await act(async () => {
|
|
424
|
+
serviceMap!.respond(
|
|
425
|
+
serviceMapPayload(serviceMap!.rangeStart, "after unmount"),
|
|
426
|
+
);
|
|
427
|
+
infrastructure!.reject(new Error("Request Canceled."));
|
|
428
|
+
});
|
|
429
|
+
await flush();
|
|
430
|
+
|
|
431
|
+
/* No retry, no reload, and no React warning about the dead component. */
|
|
432
|
+
expect(postMock).toHaveBeenCalledTimes(2);
|
|
433
|
+
expect(unexpectedConsoleErrors()).toEqual([]);
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
describe("a project change mid-flight", () => {
|
|
438
|
+
test("drops the old project's ready tab, aborts its background load, and reloads only the active tab for the new project", async () => {
|
|
439
|
+
autoRespond = true;
|
|
440
|
+
const view: ReturnType<typeof render> = render(
|
|
441
|
+
<Probe range={RANGE} view="Service Map" />,
|
|
442
|
+
);
|
|
443
|
+
await flush();
|
|
444
|
+
expect(text("sm-entities")).toBe("service map #1");
|
|
445
|
+
|
|
446
|
+
/* Infrastructure starts loading, then the reader goes back. */
|
|
447
|
+
autoRespond = false;
|
|
448
|
+
view.rerender(<Probe range={RANGE} view="Infrastructure" />);
|
|
449
|
+
await flush();
|
|
450
|
+
view.rerender(<Probe range={RANGE} view="Service Map" />);
|
|
451
|
+
await flush();
|
|
452
|
+
const backgroundInfrastructure: RecordedRequest = requestsTo(
|
|
453
|
+
TopologyApiPath.Infrastructure,
|
|
454
|
+
)[0]!;
|
|
455
|
+
expect(backgroundInfrastructure.tenant).toBe(PROJECT_ONE.toString());
|
|
456
|
+
expect(text("infra-status")).toBe("loading");
|
|
457
|
+
|
|
458
|
+
getProjectIdMock.mockReturnValue(PROJECT_TWO);
|
|
459
|
+
view.rerender(<Probe range={RANGE} view="Service Map" />);
|
|
460
|
+
await flush();
|
|
461
|
+
|
|
462
|
+
expect(backgroundInfrastructure.signal?.aborted).toBe(true);
|
|
463
|
+
const serviceMap: Array<RecordedRequest> = requestsTo(
|
|
464
|
+
TopologyApiPath.ServiceMap,
|
|
465
|
+
);
|
|
466
|
+
expect(serviceMap).toHaveLength(2);
|
|
467
|
+
expect(serviceMap[1]!.tenant).toBe(PROJECT_TWO.toString());
|
|
468
|
+
/* Project one's map is gone while project two's loads. */
|
|
469
|
+
expect(text("sm-status")).toBe("loading");
|
|
470
|
+
expect(text("sm-entities")).toBe("");
|
|
471
|
+
/* The background tab is dropped, not reloaded behind the reader's back. */
|
|
472
|
+
expect(text("infra-status")).toBe("idle");
|
|
473
|
+
expect(requestsTo(TopologyApiPath.Infrastructure)).toHaveLength(1);
|
|
474
|
+
|
|
475
|
+
await act(async () => {
|
|
476
|
+
backgroundInfrastructure.respond(
|
|
477
|
+
infrastructurePayload(
|
|
478
|
+
backgroundInfrastructure.rangeStart,
|
|
479
|
+
"project one infrastructure",
|
|
480
|
+
),
|
|
481
|
+
);
|
|
482
|
+
});
|
|
483
|
+
expect(text("infra-status")).toBe("idle");
|
|
484
|
+
expect(text("infra-entities")).toBe("");
|
|
485
|
+
|
|
486
|
+
await act(async () => {
|
|
487
|
+
serviceMap[1]!.respond(
|
|
488
|
+
serviceMapPayload(serviceMap[1]!.rangeStart, "project two map"),
|
|
489
|
+
);
|
|
490
|
+
});
|
|
491
|
+
expect(text("sm-entities")).toBe("project two map");
|
|
492
|
+
|
|
493
|
+
/* Opening Infrastructure now loads it for project two. */
|
|
494
|
+
view.rerender(<Probe range={RANGE} view="Infrastructure" />);
|
|
495
|
+
await flush();
|
|
496
|
+
const infrastructure: Array<RecordedRequest> = requestsTo(
|
|
497
|
+
TopologyApiPath.Infrastructure,
|
|
498
|
+
);
|
|
499
|
+
expect(infrastructure).toHaveLength(2);
|
|
500
|
+
expect(infrastructure[1]!.tenant).toBe(PROJECT_TWO.toString());
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("going to another project and back before anything answers shows only the latest request's answer", async () => {
|
|
504
|
+
const view: ReturnType<typeof render> = render(
|
|
505
|
+
<Probe range={RANGE} view="Service Map" />,
|
|
506
|
+
);
|
|
507
|
+
await flush();
|
|
508
|
+
getProjectIdMock.mockReturnValue(PROJECT_TWO);
|
|
509
|
+
view.rerender(<Probe range={RANGE} view="Service Map" />);
|
|
510
|
+
await flush();
|
|
511
|
+
getProjectIdMock.mockReturnValue(PROJECT_ONE);
|
|
512
|
+
view.rerender(<Probe range={RANGE} view="Service Map" />);
|
|
513
|
+
await flush();
|
|
514
|
+
|
|
515
|
+
expect(
|
|
516
|
+
requests.map((request: RecordedRequest): string => {
|
|
517
|
+
return request.tenant;
|
|
518
|
+
}),
|
|
519
|
+
).toEqual([
|
|
520
|
+
PROJECT_ONE.toString(),
|
|
521
|
+
PROJECT_TWO.toString(),
|
|
522
|
+
PROJECT_ONE.toString(),
|
|
523
|
+
]);
|
|
524
|
+
expect(live(requests)).toEqual([requests[2]]);
|
|
525
|
+
|
|
526
|
+
/*
|
|
527
|
+
* The first request is for the SAME project as the current one — but an
|
|
528
|
+
* older generation, so its answer must still be ignored.
|
|
529
|
+
*/
|
|
530
|
+
await act(async () => {
|
|
531
|
+
requests[0]!.respond(
|
|
532
|
+
serviceMapPayload(requests[0]!.rangeStart, "first visit"),
|
|
533
|
+
);
|
|
534
|
+
requests[1]!.respond(
|
|
535
|
+
serviceMapPayload(requests[1]!.rangeStart, "project two"),
|
|
536
|
+
);
|
|
537
|
+
});
|
|
538
|
+
expect(text("sm-status")).toBe("loading");
|
|
539
|
+
expect(text("sm-entities")).toBe("");
|
|
540
|
+
|
|
541
|
+
await act(async () => {
|
|
542
|
+
requests[2]!.respond(
|
|
543
|
+
serviceMapPayload(requests[2]!.rangeStart, "second visit"),
|
|
544
|
+
);
|
|
545
|
+
});
|
|
546
|
+
expect(text("sm-entities")).toBe("second visit");
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("losing the project mid-flight aborts the request and asks for a project without sending another", async () => {
|
|
550
|
+
const view: ReturnType<typeof render> = render(
|
|
551
|
+
<Probe range={RANGE} view="Service Map" />,
|
|
552
|
+
);
|
|
553
|
+
await flush();
|
|
554
|
+
expect(requests).toHaveLength(1);
|
|
555
|
+
|
|
556
|
+
getProjectIdMock.mockReturnValue(null);
|
|
557
|
+
view.rerender(<Probe range={RANGE} view="Service Map" />);
|
|
558
|
+
await flush();
|
|
559
|
+
|
|
560
|
+
expect(requests[0]!.signal?.aborted).toBe(true);
|
|
561
|
+
expect(requests).toHaveLength(1);
|
|
562
|
+
expect(text("sm-status")).toBe("error");
|
|
563
|
+
expect(text("sm-error")).toContain("Select a project");
|
|
564
|
+
|
|
565
|
+
/* The aborted answer cannot resurrect the other project's map. */
|
|
566
|
+
await act(async () => {
|
|
567
|
+
requests[0]!.respond(
|
|
568
|
+
serviceMapPayload(requests[0]!.rangeStart, "project one"),
|
|
569
|
+
);
|
|
570
|
+
});
|
|
571
|
+
expect(text("sm-status")).toBe("error");
|
|
572
|
+
expect(text("sm-entities")).toBe("");
|
|
573
|
+
|
|
574
|
+
/* A project arriving loads normally, scoped to it. */
|
|
575
|
+
autoRespond = true;
|
|
576
|
+
getProjectIdMock.mockReturnValue(PROJECT_TWO);
|
|
577
|
+
view.rerender(<Probe range={RANGE} view="Service Map" />);
|
|
578
|
+
await flush();
|
|
579
|
+
expect(requests).toHaveLength(2);
|
|
580
|
+
expect(requests[1]!.tenant).toBe(PROJECT_TWO.toString());
|
|
581
|
+
expect(text("sm-status")).toBe("ready");
|
|
582
|
+
});
|
|
583
|
+
});
|