@oneuptime/common 14.0.2 → 14.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/DatabaseModels/AlertFeed.ts +2 -0
- package/Models/DatabaseModels/IncidentAlert.ts +540 -0
- package/Models/DatabaseModels/IncidentFeed.ts +2 -0
- package/Models/DatabaseModels/Index.ts +2 -0
- package/Models/DatabaseModels/Project.ts +60 -0
- package/Models/DatabaseModels/RumApplication.ts +52 -1
- package/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.ts +28 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.ts +89 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
- package/Server/Services/IncidentAlertService.ts +1811 -0
- package/Server/Services/IncidentService.ts +210 -10
- package/Server/Services/IncidentStateTimelineService.ts +24 -0
- package/Server/Services/Index.ts +2 -0
- package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +24 -2
- package/Tests/App/Dashboard/BulkIncidentLinkActions.test.tsx +869 -0
- package/Tests/App/Dashboard/CephMetricTooltips.test.tsx +1777 -0
- package/Tests/App/Dashboard/CloudFleetSummary.test.ts +8 -0
- package/Tests/App/Dashboard/CloudMetricTooltips.test.tsx +887 -0
- package/Tests/App/Dashboard/ContainerHostMetricTooltips.test.tsx +1309 -0
- package/Tests/App/Dashboard/DiscoveryScanLivePage.test.tsx +39 -0
- package/Tests/App/Dashboard/DockerSwarmClustersListTooltips.test.tsx +414 -0
- package/Tests/App/Dashboard/DockerSwarmMetricTooltips.test.ts +796 -0
- package/Tests/App/Dashboard/DockerSwarmOverviewTooltips.test.tsx +747 -0
- package/Tests/App/Dashboard/ExceptionMonitorEnvironmentFilter.test.tsx +38 -18
- package/Tests/App/Dashboard/HostDetailViewTooltips.test.tsx +725 -0
- package/Tests/App/Dashboard/HostListColumnTooltips.test.tsx +472 -0
- package/Tests/App/Dashboard/HostMetricTooltips.test.ts +709 -0
- package/Tests/App/Dashboard/HostOverviewTooltips.test.tsx +698 -0
- package/Tests/App/Dashboard/HostTooltipHarness.ts +124 -0
- package/Tests/App/Dashboard/HostsListTooltips.test.tsx +395 -0
- package/Tests/App/Dashboard/IncidentAlertLinkDialog.test.tsx +691 -0
- package/Tests/App/Dashboard/IncidentAlertLinkHelpers.test.ts +458 -0
- package/Tests/App/Dashboard/IncidentAlertLinkPages.test.tsx +828 -0
- package/Tests/App/Dashboard/IncidentAlertLinkSideMenus.test.tsx +231 -0
- package/Tests/App/Dashboard/IncidentCreateFromAlerts.test.tsx +689 -0
- package/Tests/App/Dashboard/IoTMetricTooltips.test.tsx +1123 -0
- package/Tests/App/Dashboard/KubernetesClusterMetricTooltips.test.ts +837 -0
- package/Tests/App/Dashboard/KubernetesClusterOverviewTooltips.test.tsx +699 -0
- package/Tests/App/Dashboard/KubernetesContainersTabTooltips.test.tsx +320 -0
- package/Tests/App/Dashboard/KubernetesCostTooltips.test.tsx +502 -0
- package/Tests/App/Dashboard/KubernetesInsightsTooltips.test.tsx +222 -0
- package/Tests/App/Dashboard/KubernetesResourceMetricTooltips.test.ts +561 -0
- package/Tests/App/Dashboard/KubernetesResourcePagesTooltips.test.tsx +1168 -0
- package/Tests/App/Dashboard/LinkIncidentAlertModal.test.tsx +154 -0
- package/Tests/App/Dashboard/MetricDescriptionRules.ts +170 -0
- package/Tests/App/Dashboard/MetricDescriptionsCatalog.test.ts +206 -0
- package/Tests/App/Dashboard/MetricDescriptionsConsistency.test.ts +275 -0
- package/Tests/App/Dashboard/NetworkDeviceTooltips.test.tsx +852 -0
- package/Tests/App/Dashboard/NetworkDiagnosticsTooltips.test.tsx +556 -0
- package/Tests/App/Dashboard/NetworkMetricTooltips.test.ts +1348 -0
- package/Tests/App/Dashboard/NetworkSiteTooltips.test.tsx +679 -0
- package/Tests/App/Dashboard/ProxmoxMetricTooltips.test.ts +592 -0
- package/Tests/App/Dashboard/ProxmoxOverviewTooltips.test.tsx +1002 -0
- package/Tests/App/Dashboard/ProxmoxResourcePageTooltips.test.tsx +923 -0
- package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +1066 -0
- package/Tests/App/Dashboard/RumOverviewPage.test.tsx +1195 -0
- package/Tests/App/Dashboard/ServerlessMetricTooltips.test.tsx +474 -0
- package/Tests/App/Dashboard/ServiceMetricTooltips.test.tsx +772 -0
- package/Tests/App/Dashboard/SessionReplayAuditTable.test.tsx +56 -1
- package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +1207 -0
- package/Tests/App/Dashboard/TelemetryResourceTabTooltips.test.tsx +437 -0
- package/Tests/App/Dashboard/VMwareMetricTooltips.test.ts +801 -0
- package/Tests/App/Dashboard/VMwareOverviewTooltips.test.tsx +601 -0
- package/Tests/App/Dashboard/VMwareResourcePagesTooltips.test.tsx +646 -0
- package/Tests/App/Dashboard/VMwareTooltipHarness.ts +145 -0
- package/Tests/Models/DatabaseModels/IncidentAlertModel.test.ts +554 -0
- package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +117 -0
- package/Tests/Server/Infrastructure/Postgres/AddIncidentAlertMigration.test.ts +341 -0
- package/Tests/Server/Infrastructure/Postgres/AddSessionReplaySameOriginTracePropagationMigration.test.ts +247 -0
- package/Tests/Server/Services/IncidentAlertPostgres.test.ts +903 -0
- package/Tests/Server/Services/IncidentAlertRegistration.test.ts +96 -0
- package/Tests/Server/Services/IncidentAlertService.test.ts +2225 -0
- package/Tests/Server/Services/IncidentAlertStateCascade.test.ts +970 -0
- package/Tests/Server/Services/IncidentCreateFromAlerts.test.ts +1284 -0
- package/Tests/Server/Utils/SessionReplay/SessionReplayGateCachePolicy.test.ts +103 -0
- package/Tests/Types/Rum/WebVitals.test.ts +173 -0
- package/Tests/UI/Components/FieldLabelInfoCardTooltip.test.tsx +413 -0
- package/Tests/UI/Components/InfoTooltip.test.tsx +242 -0
- package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +207 -3
- package/Tests/UI/Components/ModelTable/BaseModelTableBulkDeleteVerb.test.tsx +367 -0
- package/Tests/UI/Components/ModelTable/BaseModelTableCreateButtonTitle.test.tsx +275 -0
- package/Tests/UI/Components/TableHeaderTooltip.test.tsx +662 -0
- package/Tests/UI/Rum/PrivacySummaryCard.test.tsx +166 -9
- package/Tests/UI/Rum/RecordingHealthTooltips.test.tsx +447 -0
- package/Tests/UI/Rum/ReplayCorrelationPanel.test.tsx +164 -0
- package/Tests/UI/Rum/ReplayFrameCapture.test.ts +6750 -0
- package/Tests/UI/Rum/ReplayRail.test.tsx +723 -12
- package/Tests/UI/Rum/ReplayRailDetail.test.tsx +86 -0
- package/Tests/UI/Rum/ReplayScreenshot.test.ts +2124 -0
- package/Tests/UI/Rum/ReplayScreenshotActions.test.tsx +3288 -0
- package/Tests/UI/Rum/ReplayStage.test.tsx +62 -0
- package/Tests/UI/Rum/ReplayStageOverlays.test.tsx +983 -2
- package/Tests/UI/Rum/SessionReplaySettingsPolicyLoading.test.tsx +149 -0
- package/Tests/UI/Rum/SessionReplaySetupGuide.test.tsx +107 -6
- package/Tests/UI/Rum/SessionReplayUsersTableTooltips.test.tsx +330 -0
- package/Tests/UI/Rum/UserFlowTooltips.test.tsx +886 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
- package/Tests/UI/Utils/PermissionGate.test.ts +76 -0
- package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +696 -0
- package/Tests/Utils/Rum/SessionTraceState.test.ts +239 -0
- package/Types/Incident/IncidentAlertLink.ts +38 -0
- package/Types/Permission.ts +48 -0
- package/Types/Rum/SessionReplay.ts +25 -6
- package/Types/Rum/WebVitals.ts +52 -0
- package/UI/Components/Detail/FieldLabel.tsx +8 -0
- package/UI/Components/InfoCard/InfoCard.tsx +47 -1
- package/UI/Components/ModelTable/BaseModelTable.tsx +95 -17
- package/UI/Components/ModelTable/Column.ts +5 -0
- package/UI/Components/Table/TableHeader.tsx +47 -2
- package/UI/Components/Table/Types/Column.ts +7 -0
- package/UI/Components/Tooltip/InfoTooltip.tsx +78 -0
- package/UI/Utils/PermissionGate.ts +11 -2
- package/Utils/Incident/IncidentFromAlerts.ts +516 -0
- package/Utils/Rum/SessionTraceState.ts +212 -0
- package/build/dist/Models/DatabaseModels/AlertFeed.js +2 -0
- package/build/dist/Models/DatabaseModels/AlertFeed.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentAlert.js +553 -0
- package/build/dist/Models/DatabaseModels/IncidentAlert.js.map +1 -0
- package/build/dist/Models/DatabaseModels/IncidentFeed.js +2 -0
- package/build/dist/Models/DatabaseModels/IncidentFeed.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Index.js +2 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Project.js +62 -0
- package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
- package/build/dist/Models/DatabaseModels/RumApplication.js +53 -1
- package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js +20 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js +42 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/IncidentAlertService.js +1370 -0
- package/build/dist/Server/Services/IncidentAlertService.js.map +1 -0
- package/build/dist/Server/Services/IncidentService.js +164 -12
- package/build/dist/Server/Services/IncidentService.js.map +1 -1
- package/build/dist/Server/Services/IncidentStateTimelineService.js +21 -0
- package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +2 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js +8 -0
- package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
- package/build/dist/Types/Incident/IncidentAlertLink.js +34 -0
- package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -0
- package/build/dist/Types/Permission.js +42 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/Rum/WebVitals.js +25 -0
- package/build/dist/Types/Rum/WebVitals.js.map +1 -1
- package/build/dist/UI/Components/Detail/FieldLabel.js +2 -0
- package/build/dist/UI/Components/Detail/FieldLabel.js.map +1 -1
- package/build/dist/UI/Components/InfoCard/InfoCard.js +22 -1
- package/build/dist/UI/Components/InfoCard/InfoCard.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js +66 -14
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
- package/build/dist/UI/Components/Table/TableHeader.js +18 -2
- package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
- package/build/dist/UI/Components/Tooltip/InfoTooltip.js +28 -0
- package/build/dist/UI/Components/Tooltip/InfoTooltip.js.map +1 -0
- package/build/dist/UI/Utils/PermissionGate.js +4 -2
- package/build/dist/UI/Utils/PermissionGate.js.map +1 -1
- package/build/dist/Utils/Incident/IncidentFromAlerts.js +306 -0
- package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -0
- package/build/dist/Utils/Rum/SessionTraceState.js +154 -0
- package/build/dist/Utils/Rum/SessionTraceState.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1123 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
import {
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
jest,
|
|
8
|
+
test,
|
|
9
|
+
} from "@jest/globals";
|
|
10
|
+
import {
|
|
11
|
+
act,
|
|
12
|
+
cleanup,
|
|
13
|
+
fireEvent,
|
|
14
|
+
render,
|
|
15
|
+
screen,
|
|
16
|
+
within,
|
|
17
|
+
} from "@testing-library/react";
|
|
18
|
+
import * as React from "react";
|
|
19
|
+
import getJestMockFunction, { MockFunction } from "../../MockType";
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* The (i) tooltips on the IoT fleet pages: the fleet overview tiles and the
|
|
23
|
+
* "Devices needing attention" list, the device detail summary fields, and
|
|
24
|
+
* the metric columns of the Devices and Fleets tables.
|
|
25
|
+
*
|
|
26
|
+
* The overview and the device detail are rendered for real with only the
|
|
27
|
+
* network replaced; the two ModelTable pages are rendered with the table
|
|
28
|
+
* replaced by a recorder, so the columns they build - header tooltip and
|
|
29
|
+
* cell renderer - can be read and run.
|
|
30
|
+
*
|
|
31
|
+
* The descriptions make specific claims ("each device's latest reading in
|
|
32
|
+
* the selected time range", "falls back to the fleet's most recent data",
|
|
33
|
+
* "battery below 20%, only if reported in the last 15 minutes", "red at 20%
|
|
34
|
+
* or below"). Each claim is checked against what the page shows for data
|
|
35
|
+
* that tells the difference.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const FLEET_ID: string = "0193c0de-7777-4aaa-8bbb-000000000007";
|
|
39
|
+
const NOW: Date = new Date("2026-09-24T12:00:00.000Z");
|
|
40
|
+
const MINUTE: number = 60 * 1000;
|
|
41
|
+
// Where one sentence ends and the next begins.
|
|
42
|
+
const SENTENCE_BREAK: RegExp = /(?<=[.!?])\s+(?=[A-Z])/;
|
|
43
|
+
const MIB: number = 1024 * 1024;
|
|
44
|
+
|
|
45
|
+
const modelGetItemMock: MockFunction = getJestMockFunction();
|
|
46
|
+
const modelGetListMock: MockFunction = getJestMockFunction();
|
|
47
|
+
const analyticsAggregateMock: MockFunction = getJestMockFunction();
|
|
48
|
+
const modelTableMock: MockFunction = getJestMockFunction();
|
|
49
|
+
|
|
50
|
+
let mockLastParam: string = "dev-b";
|
|
51
|
+
|
|
52
|
+
jest.mock("react-i18next", () => {
|
|
53
|
+
return {
|
|
54
|
+
useTranslation: () => {
|
|
55
|
+
return {
|
|
56
|
+
t: (key: string, opts?: { defaultValue?: string }): string => {
|
|
57
|
+
return opts?.defaultValue ?? key;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
|
|
65
|
+
return {
|
|
66
|
+
__esModule: true,
|
|
67
|
+
default: {
|
|
68
|
+
getItem: (...args: Array<unknown>): unknown => {
|
|
69
|
+
return modelGetItemMock(...args);
|
|
70
|
+
},
|
|
71
|
+
getList: (...args: Array<unknown>): unknown => {
|
|
72
|
+
return modelGetListMock(...args);
|
|
73
|
+
},
|
|
74
|
+
count: (): Promise<number> => {
|
|
75
|
+
return Promise.resolve(1);
|
|
76
|
+
},
|
|
77
|
+
getCommonHeaders: (): Record<string, string> => {
|
|
78
|
+
return {};
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
jest.mock("../../../UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI", () => {
|
|
85
|
+
return {
|
|
86
|
+
__esModule: true,
|
|
87
|
+
default: {
|
|
88
|
+
aggregate: (...args: Array<unknown>): unknown => {
|
|
89
|
+
return analyticsAggregateMock(...args);
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
jest.mock("../../../UI/Utils/Navigation", () => {
|
|
96
|
+
return {
|
|
97
|
+
__esModule: true,
|
|
98
|
+
default: {
|
|
99
|
+
getLastParamAsObjectID: (): unknown => {
|
|
100
|
+
const { default: ObjectIDType } = jest.requireActual(
|
|
101
|
+
"../../../Types/ObjectID",
|
|
102
|
+
) as { default: new (id: string) => unknown };
|
|
103
|
+
return new ObjectIDType("0193c0de-7777-4aaa-8bbb-000000000007");
|
|
104
|
+
},
|
|
105
|
+
getLastParamAsString: (): string => {
|
|
106
|
+
return mockLastParam;
|
|
107
|
+
},
|
|
108
|
+
navigate: (): void => {},
|
|
109
|
+
isOnThisPage: (): boolean => {
|
|
110
|
+
return false;
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
jest.mock("../../../UI/Utils/Project", () => {
|
|
117
|
+
return {
|
|
118
|
+
__esModule: true,
|
|
119
|
+
default: {
|
|
120
|
+
getCurrentProjectId: (): unknown => {
|
|
121
|
+
const { default: ObjectIDType } = jest.requireActual(
|
|
122
|
+
"../../../Types/ObjectID",
|
|
123
|
+
) as { default: new (id: string) => unknown };
|
|
124
|
+
return new ObjectIDType("10000000-0000-4000-8000-000000000001");
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
jest.mock(
|
|
131
|
+
"../../../UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker",
|
|
132
|
+
() => {
|
|
133
|
+
return {
|
|
134
|
+
__esModule: true,
|
|
135
|
+
default: (props: { onChange: (value: unknown) => void }) => {
|
|
136
|
+
return (
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
onClick={() => {
|
|
140
|
+
props.onChange({ range: "Past 1 Day" });
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
Pick past day
|
|
144
|
+
</button>
|
|
145
|
+
);
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
jest.mock(
|
|
152
|
+
"../../../../App/FeatureSet/Dashboard/src/Components/TelemetryResource/AutoRefreshControl",
|
|
153
|
+
() => {
|
|
154
|
+
return {
|
|
155
|
+
__esModule: true,
|
|
156
|
+
default: (props: { timeRangePicker?: React.ReactNode }) => {
|
|
157
|
+
return <div>{props.timeRangePicker}</div>;
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
jest.mock(
|
|
164
|
+
"../../../../App/FeatureSet/Dashboard/src/Components/Infrastructure/ResourceMetricsTab",
|
|
165
|
+
() => {
|
|
166
|
+
return {
|
|
167
|
+
__esModule: true,
|
|
168
|
+
default: () => {
|
|
169
|
+
return <div data-testid="resource-metrics-tab" />;
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
jest.mock("../../../UI/Components/ModelTable/ModelTable", () => {
|
|
176
|
+
return {
|
|
177
|
+
__esModule: true,
|
|
178
|
+
default: (props: unknown) => {
|
|
179
|
+
modelTableMock(props);
|
|
180
|
+
return <div data-testid="model-table" />;
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
jest.mock(
|
|
186
|
+
"../../../../App/FeatureSet/Dashboard/src/Components/ResourceOwners/useResourceOwners",
|
|
187
|
+
() => {
|
|
188
|
+
const actual: Record<string, unknown> = jest.requireActual(
|
|
189
|
+
"../../../../App/FeatureSet/Dashboard/src/Components/ResourceOwners/useResourceOwners",
|
|
190
|
+
) as Record<string, unknown>;
|
|
191
|
+
return {
|
|
192
|
+
...actual,
|
|
193
|
+
__esModule: true,
|
|
194
|
+
default: () => {
|
|
195
|
+
return {
|
|
196
|
+
getOwnersForResource: () => {
|
|
197
|
+
return [];
|
|
198
|
+
},
|
|
199
|
+
isLoadingOwners: false,
|
|
200
|
+
onResourcesFetched: () => {},
|
|
201
|
+
filterBar: null,
|
|
202
|
+
mergeFiltersIntoQuery: (query: unknown) => {
|
|
203
|
+
return query;
|
|
204
|
+
},
|
|
205
|
+
facetSaveState: undefined,
|
|
206
|
+
restoreFacetState: () => {},
|
|
207
|
+
};
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
jest.mock("../../../UI/Components/BulkUpdate/BulkLabelActions", () => {
|
|
214
|
+
return {
|
|
215
|
+
__esModule: true,
|
|
216
|
+
default: () => {
|
|
217
|
+
return { bulkActions: [], modals: null };
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
jest.mock("../../../UI/Components/BulkUpdate/BulkOwnerActions", () => {
|
|
223
|
+
return {
|
|
224
|
+
__esModule: true,
|
|
225
|
+
default: () => {
|
|
226
|
+
return { bulkActions: [], modals: null };
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
jest.mock("../../../UI/Components/BulkUpdate/BulkArchiveActions", () => {
|
|
232
|
+
return {
|
|
233
|
+
__esModule: true,
|
|
234
|
+
default: () => {
|
|
235
|
+
return { archiveBulkActions: [] };
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
import IoTFleetOverview from "../../../../App/FeatureSet/Dashboard/src/Pages/IoT/View/Index";
|
|
241
|
+
import IoTFleetDeviceDetail from "../../../../App/FeatureSet/Dashboard/src/Pages/IoT/View/DeviceDetail";
|
|
242
|
+
import IoTFleetDevices from "../../../../App/FeatureSet/Dashboard/src/Pages/IoT/View/Devices";
|
|
243
|
+
import IoTFleets from "../../../../App/FeatureSet/Dashboard/src/Pages/IoT/Fleets";
|
|
244
|
+
import {
|
|
245
|
+
IOT_METRIC_DESCRIPTIONS,
|
|
246
|
+
IoTMetric,
|
|
247
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/MetricDescriptions/IoTMetricDescriptions";
|
|
248
|
+
import { METRIC_STALE_MS } from "../../../../App/FeatureSet/Dashboard/src/Pages/IoT/Utils/IoTDeviceUtils";
|
|
249
|
+
import PageComponentProps from "../../../../App/FeatureSet/Dashboard/src/Pages/PageComponentProps";
|
|
250
|
+
import {
|
|
251
|
+
expectReadableDescriptionRecord,
|
|
252
|
+
expectTitleExplained,
|
|
253
|
+
} from "./MetricDescriptionRules";
|
|
254
|
+
|
|
255
|
+
const PAGE_PROPS: PageComponentProps = {} as PageComponentProps;
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------- fixtures
|
|
258
|
+
|
|
259
|
+
function minutesAgo(minutes: number): Date {
|
|
260
|
+
return new Date(NOW.getTime() - minutes * MINUTE);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const FLEET: Record<string, unknown> = {
|
|
264
|
+
_id: FLEET_ID,
|
|
265
|
+
name: "field-sensors",
|
|
266
|
+
description: "Soil sensors on the north farm",
|
|
267
|
+
otelCollectorStatus: "connected",
|
|
268
|
+
lastSeenAt: minutesAgo(1),
|
|
269
|
+
agentVersion: "0.9.1",
|
|
270
|
+
// The fleet's own counts from its latest data - the tiles' fallback.
|
|
271
|
+
deviceCount: 10,
|
|
272
|
+
onlineDeviceCount: 9,
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
type Row = Record<string, unknown>;
|
|
276
|
+
|
|
277
|
+
function reading(deviceId: string, minutes: number, value: number): Row {
|
|
278
|
+
return {
|
|
279
|
+
timestamp: minutesAgo(minutes),
|
|
280
|
+
value: value,
|
|
281
|
+
attributes: { "device.id": deviceId },
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/*
|
|
286
|
+
* dev-a was up 20 minutes ago and is down now; dev-c's only heartbeat is 25
|
|
287
|
+
* minutes old. "Latest heartbeat per device in the range" makes that 2 of 3
|
|
288
|
+
* online - not 9 of 10 (the fleet's counts), and not 3 of 3 (any up).
|
|
289
|
+
*/
|
|
290
|
+
const UP_SERIES: Array<Row> = [
|
|
291
|
+
reading("dev-a", 20, 1),
|
|
292
|
+
reading("dev-a", 5, 0),
|
|
293
|
+
reading("dev-b", 5, 1),
|
|
294
|
+
reading("dev-c", 25, 1),
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
/*
|
|
298
|
+
* Mean of each device's latest battery: (50 + 90) / 2 = 70. The mean of
|
|
299
|
+
* every reading would be 50.
|
|
300
|
+
*/
|
|
301
|
+
const BATTERY_SERIES: Array<Row> = [
|
|
302
|
+
reading("dev-a", 20, 10),
|
|
303
|
+
reading("dev-a", 5, 50),
|
|
304
|
+
reading("dev-b", 5, 90),
|
|
305
|
+
];
|
|
306
|
+
|
|
307
|
+
const SIGNAL_SERIES: Array<Row> = [
|
|
308
|
+
reading("dev-a", 5, -60),
|
|
309
|
+
reading("dev-b", 5, -80),
|
|
310
|
+
];
|
|
311
|
+
|
|
312
|
+
let seriesByName: Record<string, Array<Row>> = {};
|
|
313
|
+
|
|
314
|
+
const INVENTORY: Array<Row> = [
|
|
315
|
+
{
|
|
316
|
+
// Offline - listed whatever the age of its readings.
|
|
317
|
+
kind: "Device",
|
|
318
|
+
externalId: "dev-a",
|
|
319
|
+
name: "Pump A",
|
|
320
|
+
isUp: false,
|
|
321
|
+
latestBatteryPercent: 15,
|
|
322
|
+
metricsUpdatedAt: minutesAgo(20),
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
kind: "Device",
|
|
326
|
+
externalId: "dev-b",
|
|
327
|
+
name: "Gate B",
|
|
328
|
+
isUp: true,
|
|
329
|
+
latestBatteryPercent: 15,
|
|
330
|
+
latestSignalStrengthDbm: -70,
|
|
331
|
+
metricsUpdatedAt: minutesAgo(2),
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
kind: "Sensor",
|
|
335
|
+
externalId: "dev-c",
|
|
336
|
+
name: "Probe C",
|
|
337
|
+
isUp: true,
|
|
338
|
+
latestBatteryPercent: 80,
|
|
339
|
+
latestSignalStrengthDbm: -105,
|
|
340
|
+
metricsUpdatedAt: minutesAgo(2),
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
// Exactly at the limits: not "below 20%", not "weaker than -100 dBm".
|
|
344
|
+
kind: "Device",
|
|
345
|
+
externalId: "dev-d",
|
|
346
|
+
name: "Meter D",
|
|
347
|
+
isUp: true,
|
|
348
|
+
latestBatteryPercent: 20,
|
|
349
|
+
latestSignalStrengthDbm: -100,
|
|
350
|
+
metricsUpdatedAt: minutesAgo(2),
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
// Low battery, but reported 20 minutes ago - too old to count.
|
|
354
|
+
kind: "Device",
|
|
355
|
+
externalId: "dev-e",
|
|
356
|
+
name: "Tank E",
|
|
357
|
+
isUp: true,
|
|
358
|
+
latestBatteryPercent: 5,
|
|
359
|
+
metricsUpdatedAt: minutesAgo(20),
|
|
360
|
+
},
|
|
361
|
+
];
|
|
362
|
+
|
|
363
|
+
const DETAIL_ROW: Row = {
|
|
364
|
+
kind: "Sensor",
|
|
365
|
+
externalId: "dev-b",
|
|
366
|
+
name: "Gate B",
|
|
367
|
+
deviceType: "gate-controller",
|
|
368
|
+
firmwareVersion: "2.4.1",
|
|
369
|
+
isUp: true,
|
|
370
|
+
uptimeSeconds: 90061,
|
|
371
|
+
latestBatteryPercent: 15,
|
|
372
|
+
latestSignalStrengthDbm: -71.4,
|
|
373
|
+
latestTemperatureCelsius: 21.44,
|
|
374
|
+
latestCpuPercent: 12.5,
|
|
375
|
+
latestMemoryBytes: 512 * MIB,
|
|
376
|
+
maxMemoryBytes: null,
|
|
377
|
+
// Two hours old: the detail page still shows these last-sent values.
|
|
378
|
+
metricsUpdatedAt: minutesAgo(120),
|
|
379
|
+
lastSeenAt: minutesAgo(120),
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
interface CapturedAggregate {
|
|
383
|
+
name: string;
|
|
384
|
+
start: Date;
|
|
385
|
+
end: Date;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function aggregateQuery(args: unknown): CapturedAggregate {
|
|
389
|
+
const aggregateBy: {
|
|
390
|
+
query: { name: string };
|
|
391
|
+
startTimestamp: Date;
|
|
392
|
+
endTimestamp: Date;
|
|
393
|
+
} = (args as { aggregateBy: never }).aggregateBy;
|
|
394
|
+
return {
|
|
395
|
+
name: aggregateBy.query.name,
|
|
396
|
+
start: aggregateBy.startTimestamp,
|
|
397
|
+
end: aggregateBy.endTimestamp,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function arrange(): void {
|
|
402
|
+
seriesByName = {
|
|
403
|
+
iot_device_up: UP_SERIES,
|
|
404
|
+
iot_battery_percent: BATTERY_SERIES,
|
|
405
|
+
iot_signal_strength_dbm: SIGNAL_SERIES,
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
modelGetItemMock.mockImplementation(async () => {
|
|
409
|
+
return FLEET;
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
modelGetListMock.mockImplementation(async (args: unknown) => {
|
|
413
|
+
const query: Record<string, unknown> = (
|
|
414
|
+
args as { query: Record<string, unknown> }
|
|
415
|
+
).query;
|
|
416
|
+
if (query["externalId"]) {
|
|
417
|
+
const rows: Array<Row> =
|
|
418
|
+
query["externalId"] === DETAIL_ROW["externalId"] ? [DETAIL_ROW] : [];
|
|
419
|
+
return { data: rows, count: rows.length, skip: 0, limit: 1 };
|
|
420
|
+
}
|
|
421
|
+
return {
|
|
422
|
+
data: INVENTORY,
|
|
423
|
+
count: INVENTORY.length,
|
|
424
|
+
skip: 0,
|
|
425
|
+
limit: 100,
|
|
426
|
+
};
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
analyticsAggregateMock.mockImplementation(async (args: unknown) => {
|
|
430
|
+
return { data: seriesByName[aggregateQuery(args).name] || [] };
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ----------------------------------------------------------------- helpers
|
|
435
|
+
|
|
436
|
+
async function flush(): Promise<void> {
|
|
437
|
+
for (let i: number = 0; i < 8; i++) {
|
|
438
|
+
await act(async () => {
|
|
439
|
+
await Promise.resolve();
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function infoButton(title: string): HTMLElement {
|
|
445
|
+
const found: Array<HTMLElement> = screen.getAllByRole("button", {
|
|
446
|
+
name: `About ${title}`,
|
|
447
|
+
});
|
|
448
|
+
expect(found).toHaveLength(1);
|
|
449
|
+
return found[0]!;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function allInfoButtons(): Array<HTMLElement> {
|
|
453
|
+
return screen.queryAllByRole("button", { name: /^About / });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function tooltipTextOf(button: HTMLElement): Promise<string> {
|
|
457
|
+
fireEvent.mouseEnter(button);
|
|
458
|
+
await act(async () => {
|
|
459
|
+
jest.advanceTimersByTime(200);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const describedBy: string | null = button.getAttribute("aria-describedby");
|
|
463
|
+
expect(describedBy).toBeTruthy();
|
|
464
|
+
|
|
465
|
+
const text: string =
|
|
466
|
+
document.getElementById(describedBy as string)?.textContent || "";
|
|
467
|
+
|
|
468
|
+
fireEvent.mouseLeave(button);
|
|
469
|
+
|
|
470
|
+
return text;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function expectExplained(title: string, key: IoTMetric): Promise<void> {
|
|
474
|
+
expect(await tooltipTextOf(infoButton(title))).toBe(
|
|
475
|
+
IOT_METRIC_DESCRIPTIONS[key],
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function expectNoInfo(title: string): void {
|
|
480
|
+
expect(
|
|
481
|
+
screen.queryByRole("button", { name: `About ${title}` }),
|
|
482
|
+
).not.toBeInTheDocument();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function tile(title: string): HTMLElement {
|
|
486
|
+
return infoButton(title).closest("div.rounded-xl") as HTMLElement;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function tileBar(title: string): HTMLElement | null {
|
|
490
|
+
return tile(title).querySelector("div.h-1\\.5.rounded-full[style]");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function aggregateCalls(): Array<CapturedAggregate> {
|
|
494
|
+
return analyticsAggregateMock.mock.calls.map((call: Array<unknown>) => {
|
|
495
|
+
return aggregateQuery(call[0]);
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
beforeEach(() => {
|
|
500
|
+
jest.useFakeTimers();
|
|
501
|
+
jest.setSystemTime(NOW);
|
|
502
|
+
mockLastParam = "dev-b";
|
|
503
|
+
for (const mock of [
|
|
504
|
+
modelGetItemMock,
|
|
505
|
+
modelGetListMock,
|
|
506
|
+
analyticsAggregateMock,
|
|
507
|
+
modelTableMock,
|
|
508
|
+
]) {
|
|
509
|
+
mock.mockReset();
|
|
510
|
+
}
|
|
511
|
+
try {
|
|
512
|
+
window.localStorage.clear();
|
|
513
|
+
} catch {
|
|
514
|
+
// Storage is optional for these pages.
|
|
515
|
+
}
|
|
516
|
+
arrange();
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
afterEach(() => {
|
|
520
|
+
cleanup();
|
|
521
|
+
jest.useRealTimers();
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// ------------------------------------------------------------------- texts
|
|
525
|
+
|
|
526
|
+
const OVERVIEW_TITLES: Array<[string, IoTMetric]> = [
|
|
527
|
+
["Devices online", "heroDevicesOnline"],
|
|
528
|
+
["Online Devices", "onlineDevices"],
|
|
529
|
+
["Total Devices", "totalDevices"],
|
|
530
|
+
["Avg Battery", "avgBattery"],
|
|
531
|
+
["Avg Signal", "avgSignal"],
|
|
532
|
+
["Devices needing attention", "devicesNeedingAttention"],
|
|
533
|
+
];
|
|
534
|
+
|
|
535
|
+
const DETAIL_TITLES: Array<[string, IoTMetric]> = [
|
|
536
|
+
["Status", "deviceStatus"],
|
|
537
|
+
["Uptime", "uptime"],
|
|
538
|
+
["Battery", "battery"],
|
|
539
|
+
["Signal Strength", "signalStrength"],
|
|
540
|
+
["Temperature", "temperature"],
|
|
541
|
+
["CPU", "cpu"],
|
|
542
|
+
["Memory (Used / Max)", "memory"],
|
|
543
|
+
];
|
|
544
|
+
|
|
545
|
+
const DEVICES_TABLE_TITLES: Array<[string, IoTMetric]> = [
|
|
546
|
+
["Status", "statusColumn"],
|
|
547
|
+
["Battery", "batteryColumn"],
|
|
548
|
+
["Signal", "signalColumn"],
|
|
549
|
+
["Temperature", "temperatureColumn"],
|
|
550
|
+
];
|
|
551
|
+
|
|
552
|
+
const FLEETS_TABLE_TITLES: Array<[string, IoTMetric]> = [
|
|
553
|
+
["Devices", "fleetDevices"],
|
|
554
|
+
];
|
|
555
|
+
|
|
556
|
+
describe("IOT_METRIC_DESCRIPTIONS", () => {
|
|
557
|
+
test("every text is a short, finished, plain sentence and none repeats another", () => {
|
|
558
|
+
expectReadableDescriptionRecord(
|
|
559
|
+
IOT_METRIC_DESCRIPTIONS,
|
|
560
|
+
"IOT_METRIC_DESCRIPTIONS",
|
|
561
|
+
);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("every title on every IoT page is explained by its own text", () => {
|
|
565
|
+
for (const [title, key] of [
|
|
566
|
+
...OVERVIEW_TITLES,
|
|
567
|
+
...DETAIL_TITLES,
|
|
568
|
+
...DEVICES_TABLE_TITLES,
|
|
569
|
+
...FLEETS_TABLE_TITLES,
|
|
570
|
+
]) {
|
|
571
|
+
expectTitleExplained(title, IOT_METRIC_DESCRIPTIONS[key]);
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test("every key belongs to exactly one title on the pages listed here", () => {
|
|
576
|
+
const keys: Array<string> = [
|
|
577
|
+
...OVERVIEW_TITLES,
|
|
578
|
+
...DETAIL_TITLES,
|
|
579
|
+
...DEVICES_TABLE_TITLES,
|
|
580
|
+
...FLEETS_TABLE_TITLES,
|
|
581
|
+
].map(([, key]: [string, IoTMetric]) => {
|
|
582
|
+
return key;
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
expect(new Set(keys).size).toBe(keys.length);
|
|
586
|
+
expect([...keys].sort()).toEqual(
|
|
587
|
+
Object.keys(IOT_METRIC_DESCRIPTIONS).sort(),
|
|
588
|
+
);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test("dBm is explained wherever signal strength is shown", () => {
|
|
592
|
+
for (const key of [
|
|
593
|
+
"avgSignal",
|
|
594
|
+
"signalStrength",
|
|
595
|
+
"signalColumn",
|
|
596
|
+
] as Array<IoTMetric>) {
|
|
597
|
+
expect(IOT_METRIC_DESCRIPTIONS[key]).toMatch(/dBm/);
|
|
598
|
+
expect(IOT_METRIC_DESCRIPTIONS[key]).toMatch(/negative/);
|
|
599
|
+
expect(IOT_METRIC_DESCRIPTIONS[key]).toMatch(/closer to 0 is stronger/);
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("the overview tiles say they follow the time range; inventory values say they are the last ones sent", () => {
|
|
604
|
+
for (const key of [
|
|
605
|
+
"heroDevicesOnline",
|
|
606
|
+
"onlineDevices",
|
|
607
|
+
"totalDevices",
|
|
608
|
+
"avgBattery",
|
|
609
|
+
"avgSignal",
|
|
610
|
+
] as Array<IoTMetric>) {
|
|
611
|
+
expect(IOT_METRIC_DESCRIPTIONS[key]).toMatch(/selected time range/);
|
|
612
|
+
}
|
|
613
|
+
expect(IOT_METRIC_DESCRIPTIONS.devicesNeedingAttention).toMatch(
|
|
614
|
+
/ignores the time range picker/,
|
|
615
|
+
);
|
|
616
|
+
for (const key of [
|
|
617
|
+
"battery",
|
|
618
|
+
"temperature",
|
|
619
|
+
"batteryColumn",
|
|
620
|
+
] as Array<IoTMetric>) {
|
|
621
|
+
expect(IOT_METRIC_DESCRIPTIONS[key]).toMatch(
|
|
622
|
+
/last (value|one) (it )?sent/,
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test("the freshness window the attention list names is the one the page uses", () => {
|
|
628
|
+
expect(METRIC_STALE_MS).toBe(15 * MINUTE);
|
|
629
|
+
expect(IOT_METRIC_DESCRIPTIONS.devicesNeedingAttention).toMatch(
|
|
630
|
+
/last 15 minutes/,
|
|
631
|
+
);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
// A tooltip is a glance, not a paragraph: one or two sentences each.
|
|
635
|
+
test("every text is one or two sentences", () => {
|
|
636
|
+
const tooLong: Array<string> = Object.entries(IOT_METRIC_DESCRIPTIONS)
|
|
637
|
+
.filter(([, text]: [string, string]) => {
|
|
638
|
+
return text.split(SENTENCE_BREAK).length > 2;
|
|
639
|
+
})
|
|
640
|
+
.map(([key]: [string, string]) => {
|
|
641
|
+
return key;
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
expect(tooLong).toEqual([]);
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
// ---------------------------------------------------------------- overview
|
|
649
|
+
|
|
650
|
+
async function renderOverview(): Promise<void> {
|
|
651
|
+
render(<IoTFleetOverview {...PAGE_PROPS} />);
|
|
652
|
+
await screen.findByText("field-sensors");
|
|
653
|
+
await flush();
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
describe("IoT fleet overview", () => {
|
|
657
|
+
test("every metric on the page has an (i), and there are no others", async () => {
|
|
658
|
+
await renderOverview();
|
|
659
|
+
|
|
660
|
+
expect(
|
|
661
|
+
allInfoButtons()
|
|
662
|
+
.map((b: HTMLElement) => {
|
|
663
|
+
return b.getAttribute("aria-label");
|
|
664
|
+
})
|
|
665
|
+
.sort(),
|
|
666
|
+
).toEqual(
|
|
667
|
+
OVERVIEW_TITLES.map(([title]: [string, IoTMetric]) => {
|
|
668
|
+
return `About ${title}`;
|
|
669
|
+
}).sort(),
|
|
670
|
+
);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test.each(OVERVIEW_TITLES)(
|
|
674
|
+
"%s shows its IOT_METRIC_DESCRIPTIONS text",
|
|
675
|
+
async (title: string, key: IoTMetric) => {
|
|
676
|
+
await renderOverview();
|
|
677
|
+
await expectExplained(title, key);
|
|
678
|
+
},
|
|
679
|
+
);
|
|
680
|
+
|
|
681
|
+
test("no (i) sits inside a link, a button or a clickable row", async () => {
|
|
682
|
+
await renderOverview();
|
|
683
|
+
|
|
684
|
+
for (const button of allInfoButtons()) {
|
|
685
|
+
expect(button.parentElement?.closest("a, button")).toBeNull();
|
|
686
|
+
expect(button.closest(".cursor-pointer")).toBeNull();
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test("Online and Total Devices use each device's latest heartbeat in the range", async () => {
|
|
691
|
+
await renderOverview();
|
|
692
|
+
|
|
693
|
+
expect(within(tile("Online Devices")).getByText("2/3")).toBeInTheDocument();
|
|
694
|
+
expect(within(tile("Total Devices")).getByText("3")).toBeInTheDocument();
|
|
695
|
+
expect(screen.getByText("2/3 devices online")).toBeInTheDocument();
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test("with no heartbeats in the range, the tiles fall back to the fleet's most recent counts", async () => {
|
|
699
|
+
seriesByName = {};
|
|
700
|
+
await renderOverview();
|
|
701
|
+
|
|
702
|
+
expect(
|
|
703
|
+
within(tile("Online Devices")).getByText("9/10"),
|
|
704
|
+
).toBeInTheDocument();
|
|
705
|
+
expect(within(tile("Total Devices")).getByText("10")).toBeInTheDocument();
|
|
706
|
+
expect(IOT_METRIC_DESCRIPTIONS.onlineDevices).toMatch(
|
|
707
|
+
/fleet's most recent data/,
|
|
708
|
+
);
|
|
709
|
+
expect(IOT_METRIC_DESCRIPTIONS.totalDevices).toMatch(
|
|
710
|
+
/fleet's most recent data/,
|
|
711
|
+
);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
test("Avg Battery and Avg Signal average each device's latest reading, not every reading", async () => {
|
|
715
|
+
await renderOverview();
|
|
716
|
+
|
|
717
|
+
expect(within(tile("Avg Battery")).getByText("70.0%")).toBeInTheDocument();
|
|
718
|
+
expect(within(tile("Avg Signal")).getByText("-70 dBm")).toBeInTheDocument();
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test.each([
|
|
722
|
+
[55, "bg-emerald-500"],
|
|
723
|
+
[39.9, "bg-amber-500"],
|
|
724
|
+
[20, "bg-amber-500"],
|
|
725
|
+
[19.9, "bg-red-500"],
|
|
726
|
+
])(
|
|
727
|
+
"an average battery of %s%% colours the bar %s, as the text says",
|
|
728
|
+
async (battery: number, colour: string) => {
|
|
729
|
+
seriesByName["iot_battery_percent"] = [reading("dev-a", 1, battery)];
|
|
730
|
+
await renderOverview();
|
|
731
|
+
|
|
732
|
+
expect(tileBar("Avg Battery")?.className).toContain(colour);
|
|
733
|
+
expect(IOT_METRIC_DESCRIPTIONS.avgBattery).toMatch(
|
|
734
|
+
/amber below 40% and red below 20%/,
|
|
735
|
+
);
|
|
736
|
+
},
|
|
737
|
+
);
|
|
738
|
+
|
|
739
|
+
test("the tiles follow the time range picker (30 minutes by default)", async () => {
|
|
740
|
+
await renderOverview();
|
|
741
|
+
|
|
742
|
+
const windows: () => Array<number> = (): Array<number> => {
|
|
743
|
+
return aggregateCalls().map((q: CapturedAggregate) => {
|
|
744
|
+
return Math.round(
|
|
745
|
+
(new Date(q.end).getTime() - new Date(q.start).getTime()) / MINUTE,
|
|
746
|
+
);
|
|
747
|
+
});
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
expect(windows()).toEqual([30, 30, 30]);
|
|
751
|
+
|
|
752
|
+
fireEvent.click(screen.getByRole("button", { name: "Pick past day" }));
|
|
753
|
+
await flush();
|
|
754
|
+
|
|
755
|
+
expect(windows().slice(3)).toEqual([24 * 60, 24 * 60, 24 * 60]);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
test("Devices needing attention: offline devices, plus fresh low battery or weak signal", async () => {
|
|
759
|
+
await renderOverview();
|
|
760
|
+
|
|
761
|
+
const card: HTMLElement = infoButton("Devices needing attention").closest(
|
|
762
|
+
"[data-testid='card']",
|
|
763
|
+
) as HTMLElement;
|
|
764
|
+
const rows: Array<string> = Array.from(
|
|
765
|
+
card.querySelectorAll("div.py-3\\.5"),
|
|
766
|
+
).map((row: Element): string => {
|
|
767
|
+
return Array.from(row.querySelectorAll("span"))
|
|
768
|
+
.map((span: Element) => {
|
|
769
|
+
return span.textContent;
|
|
770
|
+
})
|
|
771
|
+
.join("|");
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
expect(rows).toEqual([
|
|
775
|
+
// Offline first; its stale 15% battery is not repeated.
|
|
776
|
+
"Pump A|Offline",
|
|
777
|
+
"Gate B|Battery 15%",
|
|
778
|
+
"Probe C|Signal -105 dBm",
|
|
779
|
+
]);
|
|
780
|
+
// Exactly 20% / -100 dBm is not "below"; a 20-minute-old 5% is too old.
|
|
781
|
+
expect(within(card).queryByText("Meter D")).not.toBeInTheDocument();
|
|
782
|
+
expect(within(card).queryByText("Tank E")).not.toBeInTheDocument();
|
|
783
|
+
expect(IOT_METRIC_DESCRIPTIONS.devicesNeedingAttention).toMatch(
|
|
784
|
+
/battery below 20% or signal weaker than -100 dBm/,
|
|
785
|
+
);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
test("Devices needing attention ignores the time range picker", async () => {
|
|
789
|
+
await renderOverview();
|
|
790
|
+
|
|
791
|
+
const inventoryCalls: number = modelGetListMock.mock.calls.length;
|
|
792
|
+
for (const call of modelGetListMock.mock.calls) {
|
|
793
|
+
const query: Record<string, unknown> = (
|
|
794
|
+
call[0] as { query: Record<string, unknown> }
|
|
795
|
+
).query;
|
|
796
|
+
expect(Object.keys(query).sort()).toEqual(["iotFleetId"]);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
fireEvent.click(screen.getByRole("button", { name: "Pick past day" }));
|
|
800
|
+
await flush();
|
|
801
|
+
|
|
802
|
+
expect(modelGetListMock.mock.calls.length).toBe(inventoryCalls);
|
|
803
|
+
expect(screen.getByText("Pump A")).toBeInTheDocument();
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
/*
|
|
808
|
+
* The "2/3 devices online" chip beside the fleet name. Its text claims it
|
|
809
|
+
* is counted like the Online Devices tile, and that it shows the fleet's
|
|
810
|
+
* own counts until the heartbeats first load or when none arrived.
|
|
811
|
+
*/
|
|
812
|
+
describe("IoT fleet overview: the devices-online chip", () => {
|
|
813
|
+
function chipRow(): HTMLElement {
|
|
814
|
+
return infoButton("Devices online").parentElement as HTMLElement;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
test("sits in the chip row, with the (i) beside the count and the agent version", async () => {
|
|
818
|
+
await renderOverview();
|
|
819
|
+
|
|
820
|
+
expect(within(chipRow()).getByText("2/3 devices online")).toBeVisible();
|
|
821
|
+
expect(within(chipRow()).getByText("Agent 0.9.1")).toBeVisible();
|
|
822
|
+
await expectExplained("Devices online", "heroDevicesOnline");
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
test("shows the same count as the Online Devices tile, from each device's latest heartbeat in the range", async () => {
|
|
826
|
+
await renderOverview();
|
|
827
|
+
|
|
828
|
+
expect(within(chipRow()).getByText("2/3 devices online")).toBeVisible();
|
|
829
|
+
expect(within(tile("Online Devices")).getByText("2/3")).toBeVisible();
|
|
830
|
+
// Not the fleet's own 9 of 10.
|
|
831
|
+
expect(screen.queryByText("9/10 devices online")).not.toBeInTheDocument();
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
test("follows the time range picker", async () => {
|
|
835
|
+
await renderOverview();
|
|
836
|
+
|
|
837
|
+
seriesByName = {
|
|
838
|
+
iot_device_up: [reading("dev-a", 5, 1), reading("dev-b", 5, 1)],
|
|
839
|
+
};
|
|
840
|
+
fireEvent.click(screen.getByRole("button", { name: "Pick past day" }));
|
|
841
|
+
await flush();
|
|
842
|
+
|
|
843
|
+
expect(within(chipRow()).getByText("2/2 devices online")).toBeVisible();
|
|
844
|
+
expect(IOT_METRIC_DESCRIPTIONS.heroDevicesOnline).toMatch(
|
|
845
|
+
/selected time range/,
|
|
846
|
+
);
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
test("with no heartbeats in the range, it falls back to the fleet's most recent counts", async () => {
|
|
850
|
+
seriesByName = {};
|
|
851
|
+
await renderOverview();
|
|
852
|
+
|
|
853
|
+
expect(within(chipRow()).getByText("9/10 devices online")).toBeVisible();
|
|
854
|
+
expect(IOT_METRIC_DESCRIPTIONS.heroDevicesOnline).toMatch(
|
|
855
|
+
/if none arrived in the range, it shows the counts from the fleet's most recent data/,
|
|
856
|
+
);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
test("until the heartbeats first load, it shows the fleet's counts while the tiles are still loading", async () => {
|
|
860
|
+
analyticsAggregateMock.mockImplementation(() => {
|
|
861
|
+
return new Promise<never>(() => {});
|
|
862
|
+
});
|
|
863
|
+
render(<IoTFleetOverview {...PAGE_PROPS} />);
|
|
864
|
+
await screen.findByText("field-sensors");
|
|
865
|
+
await flush();
|
|
866
|
+
|
|
867
|
+
expect(within(chipRow()).getByText("9/10 devices online")).toBeVisible();
|
|
868
|
+
// The tiles are skeletons, so their (i)s are not there yet.
|
|
869
|
+
expectNoInfo("Online Devices");
|
|
870
|
+
expect(IOT_METRIC_DESCRIPTIONS.heroDevicesOnline).toMatch(
|
|
871
|
+
/Until the heartbeats first load/,
|
|
872
|
+
);
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
test("a fleet with no devices has no count chip, so the agent version chip gets no (i)", async () => {
|
|
876
|
+
seriesByName = {};
|
|
877
|
+
modelGetItemMock.mockImplementation(async () => {
|
|
878
|
+
return { ...FLEET, deviceCount: 0, onlineDeviceCount: 0 };
|
|
879
|
+
});
|
|
880
|
+
await renderOverview();
|
|
881
|
+
|
|
882
|
+
expect(screen.getByText("Agent 0.9.1")).toBeVisible();
|
|
883
|
+
expect(screen.queryByText(/devices? online$/)).not.toBeInTheDocument();
|
|
884
|
+
expectNoInfo("Devices online");
|
|
885
|
+
});
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
// ----------------------------------------------------------- device detail
|
|
889
|
+
|
|
890
|
+
async function renderDetail(): Promise<void> {
|
|
891
|
+
render(<IoTFleetDeviceDetail {...PAGE_PROPS} />);
|
|
892
|
+
await screen.findByText("gate-controller");
|
|
893
|
+
await flush();
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
describe("IoT device detail", () => {
|
|
897
|
+
test.each(DETAIL_TITLES)(
|
|
898
|
+
"%s shows its IOT_METRIC_DESCRIPTIONS text",
|
|
899
|
+
async (title: string, key: IoTMetric) => {
|
|
900
|
+
await renderDetail();
|
|
901
|
+
await expectExplained(title, key);
|
|
902
|
+
},
|
|
903
|
+
);
|
|
904
|
+
|
|
905
|
+
test("identity and timestamp fields carry no (i)", async () => {
|
|
906
|
+
await renderDetail();
|
|
907
|
+
|
|
908
|
+
for (const title of [
|
|
909
|
+
"Device Name",
|
|
910
|
+
"Fleet",
|
|
911
|
+
"Kind",
|
|
912
|
+
"Device Type",
|
|
913
|
+
"Firmware Version",
|
|
914
|
+
"External ID",
|
|
915
|
+
"Last Seen",
|
|
916
|
+
]) {
|
|
917
|
+
expectNoInfo(title);
|
|
918
|
+
}
|
|
919
|
+
expect(allInfoButtons()).toHaveLength(DETAIL_TITLES.length);
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
test("the fields show the last values sent, even two hours old", async () => {
|
|
923
|
+
await renderDetail();
|
|
924
|
+
|
|
925
|
+
for (const value of [
|
|
926
|
+
"Online",
|
|
927
|
+
"1d 1h",
|
|
928
|
+
"15.0%",
|
|
929
|
+
"-71 dBm",
|
|
930
|
+
"21.4 °C",
|
|
931
|
+
"12.5%",
|
|
932
|
+
"512 MiB / —",
|
|
933
|
+
]) {
|
|
934
|
+
expect(screen.getByText(value)).toBeInTheDocument();
|
|
935
|
+
}
|
|
936
|
+
expect(IOT_METRIC_DESCRIPTIONS.memory).toMatch(
|
|
937
|
+
/dash after the slash means the device does not report its total/,
|
|
938
|
+
);
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
test("the Metrics tab has no (i) of its own - its charts carry visible descriptions", async () => {
|
|
942
|
+
await renderDetail();
|
|
943
|
+
|
|
944
|
+
fireEvent.click(screen.getByRole("tab", { name: "Metrics" }));
|
|
945
|
+
await flush();
|
|
946
|
+
|
|
947
|
+
expect(screen.getByTestId("resource-metrics-tab")).toBeInTheDocument();
|
|
948
|
+
expect(allInfoButtons()).toHaveLength(0);
|
|
949
|
+
});
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
// ----------------------------------------------------------------- tables
|
|
953
|
+
|
|
954
|
+
type CapturedColumn = {
|
|
955
|
+
title: string;
|
|
956
|
+
headerTooltip?: string | undefined;
|
|
957
|
+
getElement?: ((item: unknown) => React.ReactElement) | undefined;
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
async function capturedColumns(
|
|
961
|
+
page: React.ReactElement,
|
|
962
|
+
): Promise<Array<CapturedColumn>> {
|
|
963
|
+
render(page);
|
|
964
|
+
await flush();
|
|
965
|
+
expect(modelTableMock).toHaveBeenCalled();
|
|
966
|
+
const props: { columns: Array<CapturedColumn> } = modelTableMock.mock.calls[
|
|
967
|
+
modelTableMock.mock.calls.length - 1
|
|
968
|
+
]![0] as { columns: Array<CapturedColumn> };
|
|
969
|
+
return props.columns;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function tooltipsByTitle(
|
|
973
|
+
columns: Array<CapturedColumn>,
|
|
974
|
+
): Record<string, string | undefined> {
|
|
975
|
+
const out: Record<string, string | undefined> = {};
|
|
976
|
+
for (const column of columns) {
|
|
977
|
+
out[column.title] = column.headerTooltip;
|
|
978
|
+
}
|
|
979
|
+
return out;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function column(columns: Array<CapturedColumn>, title: string): CapturedColumn {
|
|
983
|
+
const found: CapturedColumn | undefined = columns.find(
|
|
984
|
+
(c: CapturedColumn) => {
|
|
985
|
+
return c.title === title;
|
|
986
|
+
},
|
|
987
|
+
);
|
|
988
|
+
expect(found).toBeDefined();
|
|
989
|
+
return found!;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
describe("IoT Devices table", () => {
|
|
993
|
+
test("each metric column carries its header tooltip; the rest carry none", async () => {
|
|
994
|
+
const tooltips: Record<string, string | undefined> = tooltipsByTitle(
|
|
995
|
+
await capturedColumns(<IoTFleetDevices {...PAGE_PROPS} />),
|
|
996
|
+
);
|
|
997
|
+
|
|
998
|
+
for (const [title, key] of DEVICES_TABLE_TITLES) {
|
|
999
|
+
expect({ title, tooltip: tooltips[title] }).toEqual({
|
|
1000
|
+
title,
|
|
1001
|
+
tooltip: IOT_METRIC_DESCRIPTIONS[key],
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
for (const title of ["Name", "Kind", "Device Type", "Last Seen"]) {
|
|
1005
|
+
expect({ title, tooltip: tooltips[title] }).toEqual({
|
|
1006
|
+
title,
|
|
1007
|
+
tooltip: undefined,
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
test("Battery turns red at 20% or below, as its header says", async () => {
|
|
1013
|
+
const battery: CapturedColumn = column(
|
|
1014
|
+
await capturedColumns(<IoTFleetDevices {...PAGE_PROPS} />),
|
|
1015
|
+
"Battery",
|
|
1016
|
+
);
|
|
1017
|
+
|
|
1018
|
+
const at20: ReturnType<typeof render> = render(
|
|
1019
|
+
battery.getElement!({ latestBatteryPercent: 20 }),
|
|
1020
|
+
);
|
|
1021
|
+
expect(at20.getByText("20.0%")).toHaveClass("text-red-700");
|
|
1022
|
+
at20.unmount();
|
|
1023
|
+
|
|
1024
|
+
const at21: ReturnType<typeof render> = render(
|
|
1025
|
+
battery.getElement!({ latestBatteryPercent: 21 }),
|
|
1026
|
+
);
|
|
1027
|
+
expect(at21.getByText("21.0%")).not.toHaveClass("text-red-700");
|
|
1028
|
+
expect(IOT_METRIC_DESCRIPTIONS.batteryColumn).toMatch(
|
|
1029
|
+
/red at 20% or below/,
|
|
1030
|
+
);
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
test("Status shows a dash until the first heartbeat, as its header says", async () => {
|
|
1034
|
+
const status: CapturedColumn = column(
|
|
1035
|
+
await capturedColumns(<IoTFleetDevices {...PAGE_PROPS} />),
|
|
1036
|
+
"Status",
|
|
1037
|
+
);
|
|
1038
|
+
|
|
1039
|
+
const none: ReturnType<typeof render> = render(
|
|
1040
|
+
status.getElement!({ isUp: null }),
|
|
1041
|
+
);
|
|
1042
|
+
expect(none.getByText("—")).toBeInTheDocument();
|
|
1043
|
+
none.unmount();
|
|
1044
|
+
|
|
1045
|
+
const offline: ReturnType<typeof render> = render(
|
|
1046
|
+
status.getElement!({ isUp: false }),
|
|
1047
|
+
);
|
|
1048
|
+
expect(offline.getByText("Offline")).toBeInTheDocument();
|
|
1049
|
+
expect(IOT_METRIC_DESCRIPTIONS.statusColumn).toMatch(
|
|
1050
|
+
/dash means no heartbeat has arrived yet/,
|
|
1051
|
+
);
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
test("Signal and Temperature show the device's own units", async () => {
|
|
1055
|
+
const columns: Array<CapturedColumn> = await capturedColumns(
|
|
1056
|
+
<IoTFleetDevices {...PAGE_PROPS} />,
|
|
1057
|
+
);
|
|
1058
|
+
|
|
1059
|
+
const signal: ReturnType<typeof render> = render(
|
|
1060
|
+
column(columns, "Signal").getElement!({ latestSignalStrengthDbm: -87.6 }),
|
|
1061
|
+
);
|
|
1062
|
+
expect(signal.getByText("-88 dBm")).toBeInTheDocument();
|
|
1063
|
+
signal.unmount();
|
|
1064
|
+
|
|
1065
|
+
const temperature: ReturnType<typeof render> = render(
|
|
1066
|
+
column(columns, "Temperature").getElement!({
|
|
1067
|
+
latestTemperatureCelsius: null,
|
|
1068
|
+
}),
|
|
1069
|
+
);
|
|
1070
|
+
expect(temperature.getByText("—")).toBeInTheDocument();
|
|
1071
|
+
expect(IOT_METRIC_DESCRIPTIONS.temperatureColumn).toMatch(
|
|
1072
|
+
/dash means the device does not report temperature/,
|
|
1073
|
+
);
|
|
1074
|
+
});
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
describe("IoT Fleets list", () => {
|
|
1078
|
+
test("the Devices column carries its header tooltip; the rest carry none", async () => {
|
|
1079
|
+
const tooltips: Record<string, string | undefined> = tooltipsByTitle(
|
|
1080
|
+
await capturedColumns(<IoTFleets {...PAGE_PROPS} />),
|
|
1081
|
+
);
|
|
1082
|
+
|
|
1083
|
+
for (const [title, key] of FLEETS_TABLE_TITLES) {
|
|
1084
|
+
expect({ title, tooltip: tooltips[title] }).toEqual({
|
|
1085
|
+
title,
|
|
1086
|
+
tooltip: IOT_METRIC_DESCRIPTIONS[key],
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
for (const title of ["Name", "Status", "Last Seen", "Labels", "Owners"]) {
|
|
1090
|
+
expect({ title, tooltip: tooltips[title] }).toEqual({
|
|
1091
|
+
title,
|
|
1092
|
+
tooltip: undefined,
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
test("Devices is red when any device is offline, as its header says", async () => {
|
|
1098
|
+
const devices: CapturedColumn = column(
|
|
1099
|
+
await capturedColumns(<IoTFleets {...PAGE_PROPS} />),
|
|
1100
|
+
"Devices",
|
|
1101
|
+
);
|
|
1102
|
+
|
|
1103
|
+
const someOffline: ReturnType<typeof render> = render(
|
|
1104
|
+
devices.getElement!({ deviceCount: 5, onlineDeviceCount: 4 }),
|
|
1105
|
+
);
|
|
1106
|
+
expect(someOffline.getByText("4/5 online")).toHaveClass("text-red-700");
|
|
1107
|
+
someOffline.unmount();
|
|
1108
|
+
|
|
1109
|
+
const allOnline: ReturnType<typeof render> = render(
|
|
1110
|
+
devices.getElement!({ deviceCount: 5, onlineDeviceCount: 5 }),
|
|
1111
|
+
);
|
|
1112
|
+
expect(allOnline.getByText("5/5 online")).not.toHaveClass("text-red-700");
|
|
1113
|
+
allOnline.unmount();
|
|
1114
|
+
|
|
1115
|
+
const none: ReturnType<typeof render> = render(
|
|
1116
|
+
devices.getElement!({ deviceCount: 0, onlineDeviceCount: 0 }),
|
|
1117
|
+
);
|
|
1118
|
+
expect(none.getByText("—")).toBeInTheDocument();
|
|
1119
|
+
expect(IOT_METRIC_DESCRIPTIONS.fleetDevices).toMatch(
|
|
1120
|
+
/red when any of them is offline/,
|
|
1121
|
+
);
|
|
1122
|
+
});
|
|
1123
|
+
});
|