@oneuptime/common 14.0.0 → 14.0.1
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/Index.ts +4 -0
- package/Models/DatabaseModels/ServiceLevelObjectiveFeed.ts +2 -0
- package/Models/DatabaseModels/ServiceLevelObjectiveLabelRule.ts +522 -0
- package/Models/DatabaseModels/ServiceLevelObjectiveMonitorRule.ts +38 -4
- package/Models/DatabaseModels/ServiceLevelObjectiveOwnerRule.ts +603 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1794100000000-AddSloLabelAndOwnerRules.ts +248 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1794200000000-AddSloMonitorRuleMonitorType.ts +19 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
- package/Server/Services/DatabaseService.ts +10 -0
- package/Server/Services/Index.ts +4 -0
- package/Server/Services/MonitorService.ts +3 -2
- package/Server/Services/ServiceLevelObjectiveLabelRuleEngineService.ts +385 -0
- package/Server/Services/ServiceLevelObjectiveLabelRuleService.ts +51 -0
- package/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.ts +92 -16
- package/Server/Services/ServiceLevelObjectiveMonitorRuleService.ts +36 -1
- package/Server/Services/ServiceLevelObjectiveOwnerRuleEngineService.ts +521 -0
- package/Server/Services/ServiceLevelObjectiveOwnerRuleService.ts +51 -0
- package/Server/Services/ServiceLevelObjectiveService.ts +28 -0
- package/Server/Types/Workflow/Components/Telegram/SendMessageToChat.ts +27 -1
- package/Server/Utils/AI/Toolbox/SloTools.ts +1 -0
- package/Server/Utils/Monitor/AffectedResourceList.ts +31 -73
- package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +1 -1
- package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +1 -1
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +181 -136
- package/Server/Utils/Monitor/RootCauseList.ts +139 -0
- package/Server/Utils/Rules/RuleRun/RuleRunRegistry.ts +28 -0
- package/Server/Utils/Slo/SloRulePatternValidator.ts +42 -0
- package/Tests/App/Dashboard/FeedFilterSortIntegration.test.tsx +1632 -0
- package/Tests/App/Dashboard/FeedOptionsLocales.test.ts +250 -0
- package/Tests/App/Dashboard/FeedOrdering.test.ts +279 -9
- package/Tests/App/Dashboard/InvestigationFeedRefresh.test.tsx +60 -1
- package/Tests/App/Dashboard/InvestigationPanel.test.tsx +594 -3
- package/Tests/App/Dashboard/ProductOrientation.test.tsx +646 -0
- package/Tests/App/Dashboard/ResourceFeed.test.tsx +612 -2
- package/Tests/App/Dashboard/ResourceFeedPageWiring.test.ts +30 -0
- package/Tests/App/Dashboard/ResourceFeedPagesFilterSort.test.tsx +1202 -0
- package/Tests/App/Dashboard/ScheduledMaintenanceFeedRefresh.test.tsx +52 -1
- package/Tests/App/Dashboard/SloFeed.test.tsx +106 -1
- package/Tests/App/Dashboard/SloMonitorRuleCriteria.test.tsx +566 -0
- package/Tests/App/Dashboard/SloNavigationWiring.test.tsx +6 -4
- package/Tests/App/Dashboard/SloNoticeBanner.test.tsx +186 -0
- package/Tests/App/Dashboard/SloOverviewKpiStrip.test.tsx +388 -44
- package/Tests/App/Dashboard/SloOverviewLayout.test.tsx +613 -0
- package/Tests/Models/DatabaseModels/ResourceFeedModels.test.ts +0 -8
- package/Tests/Models/DatabaseModels/SloLabelAndOwnerRuleModels.test.ts +404 -0
- package/Tests/Models/DatabaseModels/SloOverhaulModels.test.ts +17 -1
- package/Tests/Models/DatabaseModels/StaticRuleCriteriaInheritance.test.ts +4 -2
- package/Tests/Server/Infrastructure/Postgres/AddConfigurableRuleCriteriaMigration.test.ts +3 -0
- package/Tests/Server/Infrastructure/Postgres/AddSloLabelAndOwnerRulesMigration.test.ts +407 -0
- package/Tests/Server/Infrastructure/Postgres/AddSloMonitorRuleMonitorTypeMigration.test.ts +171 -0
- package/Tests/Server/Infrastructure/Postgres/SloProductOverhaulMigration.test.ts +88 -4
- package/Tests/Server/Services/DatabaseServiceRuleCriteriaValidation.test.ts +104 -0
- package/Tests/Server/Services/RuleRun/LabelRuleEnginesGroupBRun.test.ts +48 -1
- package/Tests/Server/Services/RuleRun/OwnerRuleEnginesGroupBRun.test.ts +30 -0
- package/Tests/Server/Services/ServiceLevelObjectiveLabelAndOwnerRuleEngines.test.ts +1170 -0
- package/Tests/Server/Services/ServiceLevelObjectiveLabelAndOwnerRuleServices.test.ts +172 -0
- package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.test.ts +323 -0
- package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleService.test.ts +115 -0
- package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleTriggers.test.ts +21 -2
- package/Tests/Server/Services/ServiceLevelObjectiveMonitorTypePostgres.test.ts +480 -0
- package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +162 -0
- package/Tests/Server/Services/StaticRuleCriteriaRuntimeCoverage.test.ts +45 -6
- package/Tests/Server/Types/Database/FeedEventTypeFilterQuery.test.ts +745 -0
- package/Tests/Server/Types/Markdown.test.ts +2 -1
- package/Tests/Server/Types/Workflow/Components/ApiComponentSsrf.test.ts +49 -0
- package/Tests/Server/Types/Workflow/Components/TelegramSendMessageToChat.test.ts +331 -0
- package/Tests/Server/Utils/AI/SloTools.test.ts +10 -0
- package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaAnomalyUnits.test.ts +1 -1
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluator.test.ts +405 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorBreachingSamples.test.ts +1101 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorMetricUnits.test.ts +106 -80
- package/Tests/Server/Utils/Monitor/RootCauseList.test.ts +252 -0
- package/Tests/Server/Utils/Rules/RuleRun/RuleRunPermission.test.ts +154 -0
- package/Tests/Server/Utils/SSRFProtection.test.ts +111 -0
- package/Tests/Server/Utils/Slo/SloRulePatternValidator.test.ts +67 -0
- package/Tests/Server/Utils/VM/VMRunnerSsrf.test.ts +213 -0
- package/Tests/Types/API/Route.test.ts +56 -0
- package/Tests/Types/API/URLSchemeLikePath.test.ts +304 -0
- package/Tests/UI/Components/FeedOptions.test.ts +1404 -0
- package/Tests/UI/Components/FeedOptionsButton.test.tsx +2603 -0
- package/Tests/UI/Components/Icon.test.tsx +26 -0
- package/Tests/UI/Components/RuleCriteria/RuleCriteriaModelTable.test.tsx +3 -3
- package/Tests/UI/Components/useFeedItemsViewKey.test.tsx +1017 -0
- package/Tests/UI/Components/useFeedOptions.test.tsx +1107 -0
- package/Tests/Utils/LabelRuleImportExport.test.ts +102 -0
- package/Tests/Utils/Slo/SloDuration.test.ts +115 -24
- package/Tests/Utils/Slo/SloHealth.test.ts +93 -10
- package/Tests/Utils/Slo/SloMonitorRuleCriteria.test.ts +109 -0
- package/Tests/Utils/Slo/SloOverviewText.test.ts +64 -0
- package/Types/API/Route.ts +19 -2
- package/Types/API/URL.ts +26 -2
- package/Types/Monitor/MetricMonitor/MetricCriteriaContext.ts +6 -5
- package/Types/Monitor/MetricMonitor/MetricMonitorResponse.ts +9 -0
- package/Types/Permission.ts +90 -0
- package/Types/Rules/RuleCriteriaFieldRegistry.ts +11 -0
- package/Types/Rules/RuleRun.ts +4 -0
- package/UI/Components/Feed/FeedOptions.ts +427 -0
- package/UI/Components/Feed/FeedOptionsButton.tsx +692 -0
- package/UI/Components/Feed/useFeedItems.ts +28 -9
- package/UI/Components/Feed/useFeedOptions.ts +225 -0
- package/UI/Components/Icon/Icon.tsx +1 -1
- package/Utils/LabelRuleImportExport.ts +27 -9
- package/Utils/Monitor/MetricValueFormatter.ts +2 -2
- package/Utils/Rules/MonitorTypeRuleCriteria.ts +31 -0
- package/Utils/Slo/SloDuration.ts +22 -6
- package/Utils/Slo/SloHealth.ts +8 -7
- package/Utils/Slo/SloMonitorRuleCriteria.ts +33 -1
- package/Utils/Slo/SloOverviewText.ts +33 -0
- package/Utils/Slo/SloProjection.ts +4 -3
- package/build/dist/Models/DatabaseModels/Index.js +4 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveFeed.js +2 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveFeed.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveLabelRule.js +530 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveLabelRule.js.map +1 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveMonitorRule.js +39 -4
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveMonitorRule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerRule.js +612 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerRule.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794100000000-AddSloLabelAndOwnerRules.js +93 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794100000000-AddSloLabelAndOwnerRules.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794200000000-AddSloMonitorRuleMonitorType.js +12 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794200000000-AddSloMonitorRuleMonitorType.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/DatabaseService.js +7 -0
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +4 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/MonitorService.js +3 -2
- package/build/dist/Server/Services/MonitorService.js.map +1 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleEngineService.js +295 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleEngineService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleService.js +56 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js +59 -14
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleService.js +22 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleService.js.map +1 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleEngineService.js +400 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleEngineService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleService.js +56 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveService.js +21 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/Telegram/SendMessageToChat.js +20 -1
- package/build/dist/Server/Types/Workflow/Components/Telegram/SendMessageToChat.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/SloTools.js +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/SloTools.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/AffectedResourceList.js +22 -51
- package/build/dist/Server/Utils/Monitor/AffectedResourceList.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +139 -109
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/RootCauseList.js +101 -0
- package/build/dist/Server/Utils/Monitor/RootCauseList.js.map +1 -0
- package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js +28 -0
- package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js.map +1 -1
- package/build/dist/Server/Utils/Slo/SloRulePatternValidator.js +28 -0
- package/build/dist/Server/Utils/Slo/SloRulePatternValidator.js.map +1 -0
- package/build/dist/Types/API/Route.js +18 -2
- package/build/dist/Types/API/Route.js.map +1 -1
- package/build/dist/Types/API/URL.js +23 -2
- package/build/dist/Types/API/URL.js.map +1 -1
- package/build/dist/Types/Permission.js +84 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js +11 -0
- package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js.map +1 -1
- package/build/dist/Types/Rules/RuleRun.js +4 -0
- package/build/dist/Types/Rules/RuleRun.js.map +1 -1
- package/build/dist/UI/Components/Feed/FeedOptions.js +235 -0
- package/build/dist/UI/Components/Feed/FeedOptions.js.map +1 -0
- package/build/dist/UI/Components/Feed/FeedOptionsButton.js +346 -0
- package/build/dist/UI/Components/Feed/FeedOptionsButton.js.map +1 -0
- package/build/dist/UI/Components/Feed/useFeedItems.js +17 -7
- package/build/dist/UI/Components/Feed/useFeedItems.js.map +1 -1
- package/build/dist/UI/Components/Feed/useFeedOptions.js +130 -0
- package/build/dist/UI/Components/Feed/useFeedOptions.js.map +1 -0
- package/build/dist/UI/Components/Icon/Icon.js +1 -1
- package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
- package/build/dist/Utils/LabelRuleImportExport.js +22 -6
- package/build/dist/Utils/LabelRuleImportExport.js.map +1 -1
- package/build/dist/Utils/Monitor/MetricValueFormatter.js +2 -2
- package/build/dist/Utils/Rules/MonitorTypeRuleCriteria.js +18 -0
- package/build/dist/Utils/Rules/MonitorTypeRuleCriteria.js.map +1 -0
- package/build/dist/Utils/Slo/SloDuration.js +13 -5
- package/build/dist/Utils/Slo/SloDuration.js.map +1 -1
- package/build/dist/Utils/Slo/SloHealth.js +8 -7
- package/build/dist/Utils/Slo/SloHealth.js.map +1 -1
- package/build/dist/Utils/Slo/SloMonitorRuleCriteria.js +16 -1
- package/build/dist/Utils/Slo/SloMonitorRuleCriteria.js.map +1 -1
- package/build/dist/Utils/Slo/SloOverviewText.js +16 -0
- package/build/dist/Utils/Slo/SloOverviewText.js.map +1 -1
- package/build/dist/Utils/Slo/SloProjection.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1202 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import {
|
|
4
|
+
afterEach,
|
|
5
|
+
beforeEach,
|
|
6
|
+
describe,
|
|
7
|
+
expect,
|
|
8
|
+
jest,
|
|
9
|
+
test,
|
|
10
|
+
} from "@jest/globals";
|
|
11
|
+
import "@testing-library/jest-dom";
|
|
12
|
+
import {
|
|
13
|
+
RenderResult,
|
|
14
|
+
cleanup,
|
|
15
|
+
fireEvent,
|
|
16
|
+
render,
|
|
17
|
+
screen,
|
|
18
|
+
waitFor,
|
|
19
|
+
within,
|
|
20
|
+
} from "@testing-library/react";
|
|
21
|
+
import * as React from "react";
|
|
22
|
+
|
|
23
|
+
/*
|
|
24
|
+
* ModelAPI is the only thing between these pages and the network. Stubbed
|
|
25
|
+
* inline (jest.mock is hoisted above the imports) with the "mock" prefix jest
|
|
26
|
+
* requires of anything the factory closes over - same as ResourceFeed.test.
|
|
27
|
+
*/
|
|
28
|
+
type GetListRequest = Record<string, unknown>;
|
|
29
|
+
|
|
30
|
+
const mockGetListCalls: Array<GetListRequest> = [];
|
|
31
|
+
|
|
32
|
+
jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
|
|
33
|
+
return {
|
|
34
|
+
__esModule: true,
|
|
35
|
+
default: {
|
|
36
|
+
getList: (request: GetListRequest): Promise<unknown> => {
|
|
37
|
+
mockGetListCalls.push(request);
|
|
38
|
+
return Promise.resolve({ data: [], count: 0 });
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/*
|
|
45
|
+
* The real Icon draws an SVG that says nothing about which icon it is. This
|
|
46
|
+
* one names the icon, so the checklist's icons can be read back off the page.
|
|
47
|
+
* Everything else the module exports (SizeProp, ThickProp, IconType) stays
|
|
48
|
+
* real - components across the page read those at render time.
|
|
49
|
+
*/
|
|
50
|
+
jest.mock("../../../UI/Components/Icon/Icon", () => {
|
|
51
|
+
const actualIconModule: Record<string, unknown> = jest.requireActual(
|
|
52
|
+
"../../../UI/Components/Icon/Icon",
|
|
53
|
+
) as Record<string, unknown>;
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
...actualIconModule,
|
|
57
|
+
__esModule: true,
|
|
58
|
+
default: (props: { icon: string }) => {
|
|
59
|
+
return <span data-icon={props.icon} aria-hidden="true" />;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
import KubernetesClusterFeedPage from "../../../../App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Feed";
|
|
65
|
+
import DockerHostFeedPage from "../../../../App/FeatureSet/Dashboard/src/Pages/Docker/View/Feed";
|
|
66
|
+
import DockerSwarmClusterFeedPage from "../../../../App/FeatureSet/Dashboard/src/Pages/DockerSwarm/View/Feed";
|
|
67
|
+
import PodmanHostFeedPage from "../../../../App/FeatureSet/Dashboard/src/Pages/Podman/View/Feed";
|
|
68
|
+
import ProxmoxClusterFeedPage from "../../../../App/FeatureSet/Dashboard/src/Pages/Proxmox/View/Feed";
|
|
69
|
+
import CephClusterFeedPage from "../../../../App/FeatureSet/Dashboard/src/Pages/Ceph/View/Feed";
|
|
70
|
+
import VMwareVCenterFeedPage from "../../../../App/FeatureSet/Dashboard/src/Pages/VMware/View/Feed";
|
|
71
|
+
import HostFeedPage from "../../../../App/FeatureSet/Dashboard/src/Pages/Host/View/Feed";
|
|
72
|
+
import CloudResourceFeedPage from "../../../../App/FeatureSet/Dashboard/src/Pages/Cloud/View/Feed";
|
|
73
|
+
import ServiceFeedPage from "../../../../App/FeatureSet/Dashboard/src/Pages/Service/View/Feed";
|
|
74
|
+
import SloFeedPage from "../../../../App/FeatureSet/Dashboard/src/Pages/Slo/View/Feed";
|
|
75
|
+
import SloFeed from "../../../../App/FeatureSet/Dashboard/src/Components/Slo/SloFeed";
|
|
76
|
+
import { getSloFeedEventIcon } from "../../../../App/FeatureSet/Dashboard/src/Components/Slo/SloFeedIcon";
|
|
77
|
+
import {
|
|
78
|
+
GetResourceFeedIconFunction,
|
|
79
|
+
ResourceFeedModel,
|
|
80
|
+
getIconForEventType,
|
|
81
|
+
resolveResourceFeedIcon,
|
|
82
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/ResourceFeed/ResourceFeed";
|
|
83
|
+
import type PageComponentProps from "../../../../App/FeatureSet/Dashboard/src/Pages/PageComponentProps";
|
|
84
|
+
import PageMap from "../../../../App/FeatureSet/Dashboard/src/Utils/PageMap";
|
|
85
|
+
import RouteMap from "../../../../App/FeatureSet/Dashboard/src/Utils/RouteMap";
|
|
86
|
+
import RouteParams from "../../../../App/FeatureSet/Dashboard/src/Utils/RouteParams";
|
|
87
|
+
import KubernetesClusterFeed, {
|
|
88
|
+
KubernetesClusterFeedEventType,
|
|
89
|
+
} from "../../../Models/DatabaseModels/KubernetesClusterFeed";
|
|
90
|
+
import DockerHostFeed, {
|
|
91
|
+
DockerHostFeedEventType,
|
|
92
|
+
} from "../../../Models/DatabaseModels/DockerHostFeed";
|
|
93
|
+
import DockerSwarmClusterFeed, {
|
|
94
|
+
DockerSwarmClusterFeedEventType,
|
|
95
|
+
} from "../../../Models/DatabaseModels/DockerSwarmClusterFeed";
|
|
96
|
+
import PodmanHostFeed, {
|
|
97
|
+
PodmanHostFeedEventType,
|
|
98
|
+
} from "../../../Models/DatabaseModels/PodmanHostFeed";
|
|
99
|
+
import ProxmoxClusterFeed, {
|
|
100
|
+
ProxmoxClusterFeedEventType,
|
|
101
|
+
} from "../../../Models/DatabaseModels/ProxmoxClusterFeed";
|
|
102
|
+
import CephClusterFeed, {
|
|
103
|
+
CephClusterFeedEventType,
|
|
104
|
+
} from "../../../Models/DatabaseModels/CephClusterFeed";
|
|
105
|
+
import VMwareVCenterFeed, {
|
|
106
|
+
VMwareVCenterFeedEventType,
|
|
107
|
+
} from "../../../Models/DatabaseModels/VMwareVCenterFeed";
|
|
108
|
+
import HostFeed, {
|
|
109
|
+
HostFeedEventType,
|
|
110
|
+
} from "../../../Models/DatabaseModels/HostFeed";
|
|
111
|
+
import CloudResourceFeed, {
|
|
112
|
+
CloudResourceFeedEventType,
|
|
113
|
+
} from "../../../Models/DatabaseModels/CloudResourceFeed";
|
|
114
|
+
import ServiceFeed, {
|
|
115
|
+
ServiceFeedEventType,
|
|
116
|
+
} from "../../../Models/DatabaseModels/ServiceFeed";
|
|
117
|
+
import ServiceLevelObjectiveFeed, {
|
|
118
|
+
ServiceLevelObjectiveFeedEventType,
|
|
119
|
+
} from "../../../Models/DatabaseModels/ServiceLevelObjectiveFeed";
|
|
120
|
+
import Route from "../../../Types/API/Route";
|
|
121
|
+
import Includes from "../../../Types/BaseDatabase/Includes";
|
|
122
|
+
import SortOrder from "../../../Types/BaseDatabase/SortOrder";
|
|
123
|
+
import { DEFAULT_LIMIT } from "../../../Types/Database/LimitMax";
|
|
124
|
+
import IconProp from "../../../Types/Icon/IconProp";
|
|
125
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
126
|
+
import {
|
|
127
|
+
DEFAULT_FEED_OPTIONS,
|
|
128
|
+
FEED_OPTIONS_TEXT,
|
|
129
|
+
FILTERED_FEED_NO_ITEMS_MESSAGE,
|
|
130
|
+
FeedOptions,
|
|
131
|
+
getFeedEventTypeLabel,
|
|
132
|
+
getFeedOptionsSummary,
|
|
133
|
+
} from "../../../UI/Components/Feed/FeedOptions";
|
|
134
|
+
import { getSortOrderStorageKey } from "../../../UI/Components/Feed/useFeedOptions";
|
|
135
|
+
|
|
136
|
+
/*
|
|
137
|
+
* ResourceFeed.test pins the shared component with hand-written props. What
|
|
138
|
+
* it cannot see is the eleven real pages that hand it those props - each one a
|
|
139
|
+
* near copy of the next, with two column names and an enum pasted in as
|
|
140
|
+
* strings. A slip there compiles and fails quietly: the wrong resource column
|
|
141
|
+
* is an empty feed, the wrong event type column is a filter the API cannot
|
|
142
|
+
* apply, another product's enum is a checklist of events this feed never
|
|
143
|
+
* records, and a shared storage key makes "Oldest first" on one product flip
|
|
144
|
+
* every other product's feed.
|
|
145
|
+
*
|
|
146
|
+
* So every real page is rendered at its real route with only the network
|
|
147
|
+
* stubbed, and driven through the real Filter & Sort button - plus the SLO
|
|
148
|
+
* feed embedded on the SLO overview, which renders the same ResourceFeed from
|
|
149
|
+
* a component instead of a page.
|
|
150
|
+
*/
|
|
151
|
+
|
|
152
|
+
const DASHBOARD_SRC: string = path.join(
|
|
153
|
+
__dirname,
|
|
154
|
+
"..",
|
|
155
|
+
"..",
|
|
156
|
+
"..",
|
|
157
|
+
"..",
|
|
158
|
+
"App",
|
|
159
|
+
"FeatureSet",
|
|
160
|
+
"Dashboard",
|
|
161
|
+
"src",
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const PROJECT_ID: ObjectID = ObjectID.generate();
|
|
165
|
+
|
|
166
|
+
type FeedModelType = new () => ResourceFeedModel;
|
|
167
|
+
|
|
168
|
+
type MountFunction = (resourceId: ObjectID) => React.ReactElement;
|
|
169
|
+
|
|
170
|
+
interface ResourceFeedSurface {
|
|
171
|
+
// Names the surface in test titles.
|
|
172
|
+
product: string;
|
|
173
|
+
/*
|
|
174
|
+
* The product whose feed this surface shows. Two surfaces can show one
|
|
175
|
+
* product's feed - the SLO Feed page and the feed on the SLO overview - and
|
|
176
|
+
* those are meant to agree about the reader's sort order.
|
|
177
|
+
*/
|
|
178
|
+
feedProduct: string;
|
|
179
|
+
// The file that renders <ResourceFeed>, relative to the dashboard's src.
|
|
180
|
+
file: string;
|
|
181
|
+
// That file's default export - the coverage guard checks the two agree.
|
|
182
|
+
component: unknown;
|
|
183
|
+
modelType: FeedModelType;
|
|
184
|
+
resourceIdColumn: string;
|
|
185
|
+
eventTypeColumn: string;
|
|
186
|
+
enumObject: Record<string, string>;
|
|
187
|
+
// The feed's own icons, for the event types the shared rules do not know.
|
|
188
|
+
getIcon?: GetResourceFeedIconFunction | undefined;
|
|
189
|
+
/*
|
|
190
|
+
* Puts the resource id where this surface reads it, and returns the element.
|
|
191
|
+
* Re-rendering a mounted surface with a new one's element is the dashboard
|
|
192
|
+
* moving that surface on to another resource.
|
|
193
|
+
*/
|
|
194
|
+
mount: MountFunction;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
interface FeedPageSpec {
|
|
198
|
+
product: string;
|
|
199
|
+
file: string;
|
|
200
|
+
Page: React.FunctionComponent<PageComponentProps>;
|
|
201
|
+
route: Route;
|
|
202
|
+
modelType: FeedModelType;
|
|
203
|
+
resourceIdColumn: string;
|
|
204
|
+
eventTypeColumn: string;
|
|
205
|
+
enumObject: Record<string, string>;
|
|
206
|
+
getIcon?: GetResourceFeedIconFunction | undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const FEED_PAGES: Array<FeedPageSpec> = [
|
|
210
|
+
{
|
|
211
|
+
product: "Kubernetes",
|
|
212
|
+
file: "Pages/Kubernetes/View/Feed.tsx",
|
|
213
|
+
Page: KubernetesClusterFeedPage,
|
|
214
|
+
route: RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_FEED] as Route,
|
|
215
|
+
modelType: KubernetesClusterFeed,
|
|
216
|
+
resourceIdColumn: "kubernetesClusterId",
|
|
217
|
+
eventTypeColumn: "kubernetesClusterFeedEventType",
|
|
218
|
+
enumObject: KubernetesClusterFeedEventType,
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
product: "Docker",
|
|
222
|
+
file: "Pages/Docker/View/Feed.tsx",
|
|
223
|
+
Page: DockerHostFeedPage,
|
|
224
|
+
route: RouteMap[PageMap.DOCKER_HOST_VIEW_FEED] as Route,
|
|
225
|
+
modelType: DockerHostFeed,
|
|
226
|
+
resourceIdColumn: "dockerHostId",
|
|
227
|
+
eventTypeColumn: "dockerHostFeedEventType",
|
|
228
|
+
enumObject: DockerHostFeedEventType,
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
product: "Docker Swarm",
|
|
232
|
+
file: "Pages/DockerSwarm/View/Feed.tsx",
|
|
233
|
+
Page: DockerSwarmClusterFeedPage,
|
|
234
|
+
route: RouteMap[PageMap.DOCKER_SWARM_CLUSTER_VIEW_FEED] as Route,
|
|
235
|
+
modelType: DockerSwarmClusterFeed,
|
|
236
|
+
resourceIdColumn: "dockerSwarmClusterId",
|
|
237
|
+
eventTypeColumn: "dockerSwarmClusterFeedEventType",
|
|
238
|
+
enumObject: DockerSwarmClusterFeedEventType,
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
product: "Podman",
|
|
242
|
+
file: "Pages/Podman/View/Feed.tsx",
|
|
243
|
+
Page: PodmanHostFeedPage,
|
|
244
|
+
route: RouteMap[PageMap.PODMAN_HOST_VIEW_FEED] as Route,
|
|
245
|
+
modelType: PodmanHostFeed,
|
|
246
|
+
resourceIdColumn: "podmanHostId",
|
|
247
|
+
eventTypeColumn: "podmanHostFeedEventType",
|
|
248
|
+
enumObject: PodmanHostFeedEventType,
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
product: "Proxmox",
|
|
252
|
+
file: "Pages/Proxmox/View/Feed.tsx",
|
|
253
|
+
Page: ProxmoxClusterFeedPage,
|
|
254
|
+
route: RouteMap[PageMap.PROXMOX_CLUSTER_VIEW_FEED] as Route,
|
|
255
|
+
modelType: ProxmoxClusterFeed,
|
|
256
|
+
resourceIdColumn: "proxmoxClusterId",
|
|
257
|
+
eventTypeColumn: "proxmoxClusterFeedEventType",
|
|
258
|
+
enumObject: ProxmoxClusterFeedEventType,
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
product: "Ceph",
|
|
262
|
+
file: "Pages/Ceph/View/Feed.tsx",
|
|
263
|
+
Page: CephClusterFeedPage,
|
|
264
|
+
route: RouteMap[PageMap.CEPH_CLUSTER_VIEW_FEED] as Route,
|
|
265
|
+
modelType: CephClusterFeed,
|
|
266
|
+
resourceIdColumn: "cephClusterId",
|
|
267
|
+
eventTypeColumn: "cephClusterFeedEventType",
|
|
268
|
+
enumObject: CephClusterFeedEventType,
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
product: "VMware",
|
|
272
|
+
file: "Pages/VMware/View/Feed.tsx",
|
|
273
|
+
Page: VMwareVCenterFeedPage,
|
|
274
|
+
route: RouteMap[PageMap.VMWARE_VCENTER_VIEW_FEED] as Route,
|
|
275
|
+
modelType: VMwareVCenterFeed,
|
|
276
|
+
resourceIdColumn: "vmwareVCenterId",
|
|
277
|
+
eventTypeColumn: "vmwareVCenterFeedEventType",
|
|
278
|
+
enumObject: VMwareVCenterFeedEventType,
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
product: "Host",
|
|
282
|
+
file: "Pages/Host/View/Feed.tsx",
|
|
283
|
+
Page: HostFeedPage,
|
|
284
|
+
route: RouteMap[PageMap.HOST_VIEW_FEED] as Route,
|
|
285
|
+
modelType: HostFeed,
|
|
286
|
+
resourceIdColumn: "hostId",
|
|
287
|
+
eventTypeColumn: "hostFeedEventType",
|
|
288
|
+
enumObject: HostFeedEventType,
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
product: "Cloud",
|
|
292
|
+
file: "Pages/Cloud/View/Feed.tsx",
|
|
293
|
+
Page: CloudResourceFeedPage,
|
|
294
|
+
route: RouteMap[PageMap.CLOUD_RESOURCE_VIEW_FEED] as Route,
|
|
295
|
+
modelType: CloudResourceFeed,
|
|
296
|
+
resourceIdColumn: "cloudResourceId",
|
|
297
|
+
eventTypeColumn: "cloudResourceFeedEventType",
|
|
298
|
+
enumObject: CloudResourceFeedEventType,
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
product: "Service Catalog",
|
|
302
|
+
file: "Pages/Service/View/Feed.tsx",
|
|
303
|
+
Page: ServiceFeedPage,
|
|
304
|
+
route: RouteMap[PageMap.SERVICE_VIEW_FEED] as Route,
|
|
305
|
+
modelType: ServiceFeed,
|
|
306
|
+
resourceIdColumn: "serviceId",
|
|
307
|
+
eventTypeColumn: "serviceFeedEventType",
|
|
308
|
+
enumObject: ServiceFeedEventType,
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
product: "SLO",
|
|
312
|
+
file: "Pages/Slo/View/Feed.tsx",
|
|
313
|
+
Page: SloFeedPage,
|
|
314
|
+
route: RouteMap[PageMap.SLO_VIEW_FEED] as Route,
|
|
315
|
+
modelType: ServiceLevelObjectiveFeed,
|
|
316
|
+
resourceIdColumn: "serviceLevelObjectiveId",
|
|
317
|
+
eventTypeColumn: "serviceLevelObjectiveFeedEventType",
|
|
318
|
+
enumObject: ServiceLevelObjectiveFeedEventType,
|
|
319
|
+
getIcon: getSloFeedEventIcon,
|
|
320
|
+
},
|
|
321
|
+
];
|
|
322
|
+
|
|
323
|
+
type GetFeedPageUrl = (spec: FeedPageSpec, resourceId: ObjectID) => string;
|
|
324
|
+
|
|
325
|
+
// The page's own route with the project and resource filled in.
|
|
326
|
+
const getFeedPageUrl: GetFeedPageUrl = (
|
|
327
|
+
spec: FeedPageSpec,
|
|
328
|
+
resourceId: ObjectID,
|
|
329
|
+
): string => {
|
|
330
|
+
return spec.route
|
|
331
|
+
.toString()
|
|
332
|
+
.replace(RouteParams.ProjectID, PROJECT_ID.toString())
|
|
333
|
+
.replace(RouteParams.ModelID, resourceId.toString());
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
type ToSurface = (spec: FeedPageSpec) => ResourceFeedSurface;
|
|
337
|
+
|
|
338
|
+
/*
|
|
339
|
+
* A page reads its resource from the address bar (the route is
|
|
340
|
+
* <modelId>/feed, so Navigation.getLastParamAsObjectID(1)), so mounting one
|
|
341
|
+
* means going to its URL first. It reads it on every render, so going to
|
|
342
|
+
* another resource's URL and rendering the page again is what the router does
|
|
343
|
+
* when the reader moves between two resources of one product: same route,
|
|
344
|
+
* same page, still mounted.
|
|
345
|
+
*/
|
|
346
|
+
const toSurface: ToSurface = (spec: FeedPageSpec): ResourceFeedSurface => {
|
|
347
|
+
return {
|
|
348
|
+
product: `${spec.product} feed page`,
|
|
349
|
+
feedProduct: spec.product,
|
|
350
|
+
file: spec.file,
|
|
351
|
+
component: spec.Page,
|
|
352
|
+
modelType: spec.modelType,
|
|
353
|
+
resourceIdColumn: spec.resourceIdColumn,
|
|
354
|
+
eventTypeColumn: spec.eventTypeColumn,
|
|
355
|
+
enumObject: spec.enumObject,
|
|
356
|
+
getIcon: spec.getIcon,
|
|
357
|
+
mount: (resourceId: ObjectID): React.ReactElement => {
|
|
358
|
+
window.history.pushState({}, "", getFeedPageUrl(spec, resourceId));
|
|
359
|
+
|
|
360
|
+
const pageProps: PageComponentProps = {
|
|
361
|
+
pageRoute: spec.route,
|
|
362
|
+
currentProject: null,
|
|
363
|
+
hasPaymentMethod: true,
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
return <spec.Page {...pageProps} />;
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const SLO_FEED_PAGE: FeedPageSpec = FEED_PAGES.find((spec: FeedPageSpec) => {
|
|
372
|
+
return spec.file === "Pages/Slo/View/Feed.tsx";
|
|
373
|
+
})!;
|
|
374
|
+
|
|
375
|
+
/*
|
|
376
|
+
* The SLO overview embeds the feed through SloFeed, which takes the SLO from
|
|
377
|
+
* its props rather than the URL - so the address bar is pointed somewhere
|
|
378
|
+
* else entirely, and only the prop can put the right id in the query.
|
|
379
|
+
*/
|
|
380
|
+
const EMBEDDED_SLO_FEED: ResourceFeedSurface = {
|
|
381
|
+
product: "Embedded SLO feed",
|
|
382
|
+
feedProduct: SLO_FEED_PAGE.product,
|
|
383
|
+
file: "Components/Slo/SloFeed.tsx",
|
|
384
|
+
component: SloFeed,
|
|
385
|
+
modelType: ServiceLevelObjectiveFeed,
|
|
386
|
+
resourceIdColumn: "serviceLevelObjectiveId",
|
|
387
|
+
eventTypeColumn: "serviceLevelObjectiveFeedEventType",
|
|
388
|
+
enumObject: ServiceLevelObjectiveFeedEventType,
|
|
389
|
+
getIcon: getSloFeedEventIcon,
|
|
390
|
+
mount: (resourceId: ObjectID): React.ReactElement => {
|
|
391
|
+
window.history.pushState(
|
|
392
|
+
{},
|
|
393
|
+
"",
|
|
394
|
+
getFeedPageUrl(SLO_FEED_PAGE, ObjectID.generate()),
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
return <SloFeed sloId={resourceId} />;
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const SLO_FEED_PAGE_SURFACE: ResourceFeedSurface = toSurface(SLO_FEED_PAGE);
|
|
402
|
+
|
|
403
|
+
const SURFACES: Array<ResourceFeedSurface> = [
|
|
404
|
+
...FEED_PAGES.map(toSurface),
|
|
405
|
+
EMBEDDED_SLO_FEED,
|
|
406
|
+
];
|
|
407
|
+
|
|
408
|
+
type GetEventTypes = (surface: ResourceFeedSurface) => Array<string>;
|
|
409
|
+
|
|
410
|
+
const getEventTypes: GetEventTypes = (
|
|
411
|
+
surface: ResourceFeedSurface,
|
|
412
|
+
): Array<string> => {
|
|
413
|
+
return Object.values(surface.enumObject);
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
type GetQuery = (request: GetListRequest) => Record<string, unknown>;
|
|
417
|
+
|
|
418
|
+
const getQuery: GetQuery = (
|
|
419
|
+
request: GetListRequest,
|
|
420
|
+
): Record<string, unknown> => {
|
|
421
|
+
return request["query"] as Record<string, unknown>;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
type WaitForFeed = (requestCount: number) => Promise<void>;
|
|
425
|
+
|
|
426
|
+
// The feed has asked the API `requestCount` times and drawn the last answer.
|
|
427
|
+
const waitForFeed: WaitForFeed = async (
|
|
428
|
+
requestCount: number,
|
|
429
|
+
): Promise<void> => {
|
|
430
|
+
await waitFor(() => {
|
|
431
|
+
expect(mockGetListCalls).toHaveLength(requestCount);
|
|
432
|
+
expect(screen.queryByTestId("component-loader")).not.toBeInTheDocument();
|
|
433
|
+
});
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
type GetFilterAndSortButton = () => HTMLElement;
|
|
437
|
+
|
|
438
|
+
/*
|
|
439
|
+
* The feed's header has exactly one Filter & Sort button, found the way a
|
|
440
|
+
* screen reader finds it: by its name. What the feed is showing is the
|
|
441
|
+
* button's description, not part of its name, so the name stays the label
|
|
442
|
+
* whatever the reader has chosen.
|
|
443
|
+
*/
|
|
444
|
+
const getFilterAndSortButton: GetFilterAndSortButton = (): HTMLElement => {
|
|
445
|
+
const triggers: Array<HTMLElement> = screen.getAllByRole("button", {
|
|
446
|
+
name: FEED_OPTIONS_TEXT.triggerLabel,
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
expect(triggers).toHaveLength(1);
|
|
450
|
+
|
|
451
|
+
return triggers[0]!;
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
type OpenFeedOptions = () => HTMLElement;
|
|
455
|
+
|
|
456
|
+
// Presses Filter & Sort and returns the panel it opens.
|
|
457
|
+
const openFeedOptions: OpenFeedOptions = (): HTMLElement => {
|
|
458
|
+
fireEvent.click(getFilterAndSortButton());
|
|
459
|
+
|
|
460
|
+
return screen.getByRole("dialog", { name: FEED_OPTIONS_TEXT.panelLabel });
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
type CloseFeedOptions = () => void;
|
|
464
|
+
|
|
465
|
+
// A press anywhere outside the panel - on a link to another resource, say.
|
|
466
|
+
const closeFeedOptions: CloseFeedOptions = (): void => {
|
|
467
|
+
fireEvent.mouseDown(document.body);
|
|
468
|
+
|
|
469
|
+
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
type GetExpectedSummary = (
|
|
473
|
+
surface: ResourceFeedSurface,
|
|
474
|
+
options: FeedOptions,
|
|
475
|
+
) => string;
|
|
476
|
+
|
|
477
|
+
/*
|
|
478
|
+
* The sentence the trigger is described by, counted over this surface's own
|
|
479
|
+
* event types. Built by the same function the button uses, so the wording can
|
|
480
|
+
* change without this suite noticing - what is pinned is that the description
|
|
481
|
+
* follows the feed's live order and filter, over the right product's enum.
|
|
482
|
+
*/
|
|
483
|
+
const getExpectedSummary: GetExpectedSummary = (
|
|
484
|
+
surface: ResourceFeedSurface,
|
|
485
|
+
options: FeedOptions,
|
|
486
|
+
): string => {
|
|
487
|
+
return getFeedOptionsSummary({
|
|
488
|
+
options: options,
|
|
489
|
+
eventTypeCount: getEventTypes(surface).length,
|
|
490
|
+
});
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
interface ChecklistEntry {
|
|
494
|
+
label: string;
|
|
495
|
+
icon: string | null;
|
|
496
|
+
checkbox: HTMLElement;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
type ReadChecklist = (panel: HTMLElement) => Array<ChecklistEntry>;
|
|
500
|
+
|
|
501
|
+
// The event type checklist as the reader sees it, top to bottom.
|
|
502
|
+
const readChecklist: ReadChecklist = (
|
|
503
|
+
panel: HTMLElement,
|
|
504
|
+
): Array<ChecklistEntry> => {
|
|
505
|
+
return within(panel)
|
|
506
|
+
.getAllByRole("checkbox")
|
|
507
|
+
.map((checkbox: HTMLElement): ChecklistEntry => {
|
|
508
|
+
const label: HTMLLabelElement | null = checkbox.closest("label");
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
label: label?.textContent?.trim() || "",
|
|
512
|
+
icon:
|
|
513
|
+
label?.querySelector("[data-icon]")?.getAttribute("data-icon") ||
|
|
514
|
+
null,
|
|
515
|
+
checkbox: checkbox,
|
|
516
|
+
};
|
|
517
|
+
});
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
interface ChecklistRow {
|
|
521
|
+
label: string;
|
|
522
|
+
icon: string | null;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
type ToChecklistRows = (entries: Array<ChecklistEntry>) => Array<ChecklistRow>;
|
|
526
|
+
|
|
527
|
+
const toChecklistRows: ToChecklistRows = (
|
|
528
|
+
entries: Array<ChecklistEntry>,
|
|
529
|
+
): Array<ChecklistRow> => {
|
|
530
|
+
return entries.map((entry: ChecklistEntry): ChecklistRow => {
|
|
531
|
+
return { label: entry.label, icon: entry.icon };
|
|
532
|
+
});
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
type GetStoredSortOrderKeys = () => Array<string>;
|
|
536
|
+
|
|
537
|
+
/*
|
|
538
|
+
* Every remembered feed sort order in this browser, whichever feed wrote it.
|
|
539
|
+
* The prefix is whatever getSortOrderStorageKey puts in front of a feed's
|
|
540
|
+
* name, so it is read from there rather than copied.
|
|
541
|
+
*/
|
|
542
|
+
const getStoredSortOrderKeys: GetStoredSortOrderKeys = (): Array<string> => {
|
|
543
|
+
const prefix: string = getSortOrderStorageKey("");
|
|
544
|
+
const keys: Array<string> = [];
|
|
545
|
+
|
|
546
|
+
for (let index: number = 0; index < window.localStorage.length; index++) {
|
|
547
|
+
const key: string | null = window.localStorage.key(index);
|
|
548
|
+
|
|
549
|
+
if (key && key.startsWith(prefix)) {
|
|
550
|
+
keys.push(key);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return keys;
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
type ChooseSortOrder = (label: string) => void;
|
|
558
|
+
|
|
559
|
+
const chooseSortOrder: ChooseSortOrder = (label: string): void => {
|
|
560
|
+
const panel: HTMLElement = openFeedOptions();
|
|
561
|
+
|
|
562
|
+
fireEvent.click(within(panel).getByRole("radio", { name: label }));
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
beforeEach(() => {
|
|
566
|
+
mockGetListCalls.length = 0;
|
|
567
|
+
// The chosen sort order is remembered, so one test's choice must not leak.
|
|
568
|
+
window.localStorage.clear();
|
|
569
|
+
window.history.pushState({}, "", "/");
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
afterEach(() => {
|
|
573
|
+
cleanup();
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
describe("Resource feed pages - Filter & Sort on the real pages", () => {
|
|
577
|
+
test.each(SURFACES)(
|
|
578
|
+
"$product asks the API for this resource's own feed, newest first",
|
|
579
|
+
async (surface: ResourceFeedSurface) => {
|
|
580
|
+
const resourceId: ObjectID = ObjectID.generate();
|
|
581
|
+
|
|
582
|
+
render(surface.mount(resourceId));
|
|
583
|
+
await waitForFeed(1);
|
|
584
|
+
|
|
585
|
+
const request: GetListRequest = mockGetListCalls[0]!;
|
|
586
|
+
const query: Record<string, unknown> = getQuery(request);
|
|
587
|
+
|
|
588
|
+
expect(request["modelType"]).toBe(surface.modelType);
|
|
589
|
+
|
|
590
|
+
/*
|
|
591
|
+
* Exactly the resource and nothing else: an untouched Filter & Sort adds
|
|
592
|
+
* nothing to the query, and the id is the one in the address (or, for
|
|
593
|
+
* the embedded feed, the prop) - not the project id one segment over.
|
|
594
|
+
*/
|
|
595
|
+
expect(Object.keys(query)).toEqual([surface.resourceIdColumn]);
|
|
596
|
+
expect(query[surface.resourceIdColumn]).toBeInstanceOf(ObjectID);
|
|
597
|
+
expect(String(query[surface.resourceIdColumn])).toBe(
|
|
598
|
+
resourceId.toString(),
|
|
599
|
+
);
|
|
600
|
+
|
|
601
|
+
expect(request["sort"]).toEqual({ postedAt: SortOrder.Descending });
|
|
602
|
+
expect(request["skip"]).toBe(0);
|
|
603
|
+
expect(request["limit"]).toBe(DEFAULT_LIMIT);
|
|
604
|
+
|
|
605
|
+
// The icon is chosen from the event type, so it has to be selected.
|
|
606
|
+
expect(
|
|
607
|
+
(request["select"] as Record<string, unknown>)[surface.eventTypeColumn],
|
|
608
|
+
).toBe(true);
|
|
609
|
+
|
|
610
|
+
/*
|
|
611
|
+
* The button says what the feed is showing without being opened, as
|
|
612
|
+
* its description and its tooltip.
|
|
613
|
+
*/
|
|
614
|
+
const summary: string = getExpectedSummary(surface, DEFAULT_FEED_OPTIONS);
|
|
615
|
+
const trigger: HTMLElement = getFilterAndSortButton();
|
|
616
|
+
|
|
617
|
+
expect(trigger).toHaveAccessibleDescription(summary);
|
|
618
|
+
expect(trigger).toHaveAttribute("title", summary);
|
|
619
|
+
},
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
test.each(SURFACES)(
|
|
623
|
+
"$product offers every event type of its own feed model, labelled and in order",
|
|
624
|
+
async (surface: ResourceFeedSurface) => {
|
|
625
|
+
render(surface.mount(ObjectID.generate()));
|
|
626
|
+
await waitForFeed(1);
|
|
627
|
+
|
|
628
|
+
const eventTypes: Array<string> = getEventTypes(surface);
|
|
629
|
+
const entries: Array<ChecklistEntry> = readChecklist(openFeedOptions());
|
|
630
|
+
|
|
631
|
+
expect(eventTypes.length).toBeGreaterThan(0);
|
|
632
|
+
expect(entries).toHaveLength(eventTypes.length);
|
|
633
|
+
|
|
634
|
+
/*
|
|
635
|
+
* Each entry reads as its event type's label and carries the icon the
|
|
636
|
+
* feed's items of that type carry, alphabetical so the reader can scan
|
|
637
|
+
* for the one they want. Another product's enum pasted into the page
|
|
638
|
+
* fails here by label.
|
|
639
|
+
*/
|
|
640
|
+
const expectedRows: Array<ChecklistRow> = eventTypes
|
|
641
|
+
.map((eventType: string): ChecklistRow => {
|
|
642
|
+
return {
|
|
643
|
+
label: getFeedEventTypeLabel(eventType),
|
|
644
|
+
icon: resolveResourceFeedIcon({
|
|
645
|
+
eventType: eventType,
|
|
646
|
+
getIcon: surface.getIcon,
|
|
647
|
+
}),
|
|
648
|
+
};
|
|
649
|
+
})
|
|
650
|
+
.sort((a: ChecklistRow, b: ChecklistRow): number => {
|
|
651
|
+
return a.label.localeCompare(b.label);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
expect(toChecklistRows(entries)).toEqual(expectedRows);
|
|
655
|
+
|
|
656
|
+
// Two entries reading the same would leave the reader guessing.
|
|
657
|
+
const labels: Array<string> = entries.map((entry: ChecklistEntry) => {
|
|
658
|
+
return entry.label;
|
|
659
|
+
});
|
|
660
|
+
expect(new Set(labels).size).toBe(labels.length);
|
|
661
|
+
|
|
662
|
+
// Nothing is ticked until the reader ticks it.
|
|
663
|
+
for (const entry of entries) {
|
|
664
|
+
expect(entry.checkbox).not.toBeChecked();
|
|
665
|
+
}
|
|
666
|
+
},
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
test.each(SURFACES)(
|
|
670
|
+
"$product filters on its own event type column in the API, keeping the resource",
|
|
671
|
+
async (surface: ResourceFeedSurface) => {
|
|
672
|
+
const resourceId: ObjectID = ObjectID.generate();
|
|
673
|
+
|
|
674
|
+
render(surface.mount(resourceId));
|
|
675
|
+
await waitForFeed(1);
|
|
676
|
+
|
|
677
|
+
const entries: Array<ChecklistEntry> = readChecklist(openFeedOptions());
|
|
678
|
+
const firstEntry: ChecklistEntry | undefined = entries[0];
|
|
679
|
+
|
|
680
|
+
expect(firstEntry).toBeDefined();
|
|
681
|
+
|
|
682
|
+
const tickedEventType: string | undefined = getEventTypes(surface).find(
|
|
683
|
+
(eventType: string) => {
|
|
684
|
+
return getFeedEventTypeLabel(eventType) === firstEntry!.label;
|
|
685
|
+
},
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
expect(tickedEventType).toBeDefined();
|
|
689
|
+
|
|
690
|
+
fireEvent.click(firstEntry!.checkbox);
|
|
691
|
+
await waitForFeed(2);
|
|
692
|
+
|
|
693
|
+
const request: GetListRequest = mockGetListCalls[1]!;
|
|
694
|
+
const query: Record<string, unknown> = getQuery(request);
|
|
695
|
+
|
|
696
|
+
expect(Object.keys(query).sort()).toEqual(
|
|
697
|
+
[surface.resourceIdColumn, surface.eventTypeColumn].sort(),
|
|
698
|
+
);
|
|
699
|
+
expect(String(query[surface.resourceIdColumn])).toBe(
|
|
700
|
+
resourceId.toString(),
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
const eventTypeFilter: unknown = query[surface.eventTypeColumn];
|
|
704
|
+
|
|
705
|
+
expect(eventTypeFilter).toBeInstanceOf(Includes);
|
|
706
|
+
expect((eventTypeFilter as Includes).values).toEqual([tickedEventType]);
|
|
707
|
+
|
|
708
|
+
// A new filter reads the newest window again, not the rows on screen.
|
|
709
|
+
expect(request["sort"]).toEqual({ postedAt: SortOrder.Descending });
|
|
710
|
+
expect(request["skip"]).toBe(0);
|
|
711
|
+
expect(request["limit"]).toBe(DEFAULT_LIMIT);
|
|
712
|
+
|
|
713
|
+
// Nothing matched, and the feed says the filter is why.
|
|
714
|
+
expect(
|
|
715
|
+
await screen.findByText(FILTERED_FEED_NO_ITEMS_MESSAGE),
|
|
716
|
+
).toBeInTheDocument();
|
|
717
|
+
|
|
718
|
+
/*
|
|
719
|
+
* The button's description now says the feed is narrowed - to one of
|
|
720
|
+
* this product's event types, not one of some other enum's.
|
|
721
|
+
*/
|
|
722
|
+
expect(getFilterAndSortButton()).toHaveAccessibleDescription(
|
|
723
|
+
getExpectedSummary(surface, {
|
|
724
|
+
sortOrder: SortOrder.Descending,
|
|
725
|
+
eventTypes: [tickedEventType!],
|
|
726
|
+
}),
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
/*
|
|
730
|
+
* The filter narrows one look at one resource. It is not remembered,
|
|
731
|
+
* so the next visit shows every event again.
|
|
732
|
+
*/
|
|
733
|
+
expect(getStoredSortOrderKeys()).toEqual([]);
|
|
734
|
+
|
|
735
|
+
cleanup();
|
|
736
|
+
mockGetListCalls.length = 0;
|
|
737
|
+
|
|
738
|
+
render(surface.mount(resourceId));
|
|
739
|
+
await waitForFeed(1);
|
|
740
|
+
|
|
741
|
+
expect(Object.keys(getQuery(mockGetListCalls[0]!))).toEqual([
|
|
742
|
+
surface.resourceIdColumn,
|
|
743
|
+
]);
|
|
744
|
+
},
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
test.each(SURFACES)(
|
|
748
|
+
"$product starts the next resource's feed unfiltered, from its first request",
|
|
749
|
+
async (surface: ResourceFeedSurface) => {
|
|
750
|
+
/*
|
|
751
|
+
* Moving between two resources of one product keeps the page mounted
|
|
752
|
+
* (see toSurface), so the filter ticked on the first is still in the
|
|
753
|
+
* feed's state when the second arrives. It was chosen for the first
|
|
754
|
+
* resource's investigation: carried over, the second resource's feed
|
|
755
|
+
* would open with events silently missing - and a reset that only
|
|
756
|
+
* lands after that first request still sends the API the old filter
|
|
757
|
+
* for the new resource.
|
|
758
|
+
*/
|
|
759
|
+
const firstResourceId: ObjectID = ObjectID.generate();
|
|
760
|
+
const nextResourceId: ObjectID = ObjectID.generate();
|
|
761
|
+
|
|
762
|
+
const view: RenderResult = render(surface.mount(firstResourceId));
|
|
763
|
+
await waitForFeed(1);
|
|
764
|
+
|
|
765
|
+
fireEvent.click(readChecklist(openFeedOptions())[0]!.checkbox);
|
|
766
|
+
await waitForFeed(2);
|
|
767
|
+
|
|
768
|
+
// The first resource's feed really was narrowed.
|
|
769
|
+
const filteredQuery: Record<string, unknown> = getQuery(
|
|
770
|
+
mockGetListCalls[1]!,
|
|
771
|
+
);
|
|
772
|
+
|
|
773
|
+
expect(String(filteredQuery[surface.resourceIdColumn])).toBe(
|
|
774
|
+
firstResourceId.toString(),
|
|
775
|
+
);
|
|
776
|
+
expect(filteredQuery[surface.eventTypeColumn]).toBeInstanceOf(Includes);
|
|
777
|
+
expect(
|
|
778
|
+
await screen.findByText(FILTERED_FEED_NO_ITEMS_MESSAGE),
|
|
779
|
+
).toBeInTheDocument();
|
|
780
|
+
|
|
781
|
+
// The reader follows a link to another resource of the same product.
|
|
782
|
+
closeFeedOptions();
|
|
783
|
+
view.rerender(surface.mount(nextResourceId));
|
|
784
|
+
await waitForFeed(3);
|
|
785
|
+
|
|
786
|
+
/*
|
|
787
|
+
* One request for the new resource, and it was already unfiltered -
|
|
788
|
+
* not a filtered one that a later reset replaced.
|
|
789
|
+
*/
|
|
790
|
+
const request: GetListRequest = mockGetListCalls[2]!;
|
|
791
|
+
const query: Record<string, unknown> = getQuery(request);
|
|
792
|
+
|
|
793
|
+
expect(Object.keys(query)).toEqual([surface.resourceIdColumn]);
|
|
794
|
+
expect(String(query[surface.resourceIdColumn])).toBe(
|
|
795
|
+
nextResourceId.toString(),
|
|
796
|
+
);
|
|
797
|
+
expect(request["sort"]).toEqual({ postedAt: SortOrder.Descending });
|
|
798
|
+
expect(request["skip"]).toBe(0);
|
|
799
|
+
expect(request["limit"]).toBe(DEFAULT_LIMIT);
|
|
800
|
+
|
|
801
|
+
/*
|
|
802
|
+
* The page agrees with the request: an empty feed is the resource's
|
|
803
|
+
* own empty state, not "nothing matches", and the button no longer
|
|
804
|
+
* says the feed is narrowed.
|
|
805
|
+
*/
|
|
806
|
+
expect(
|
|
807
|
+
screen.queryByText(FILTERED_FEED_NO_ITEMS_MESSAGE),
|
|
808
|
+
).not.toBeInTheDocument();
|
|
809
|
+
expect(
|
|
810
|
+
screen.queryByTestId("feed-options-count"),
|
|
811
|
+
).not.toBeInTheDocument();
|
|
812
|
+
expect(getFilterAndSortButton()).toHaveAccessibleDescription(
|
|
813
|
+
getExpectedSummary(surface, DEFAULT_FEED_OPTIONS),
|
|
814
|
+
);
|
|
815
|
+
|
|
816
|
+
for (const entry of readChecklist(openFeedOptions())) {
|
|
817
|
+
expect(entry.checkbox).not.toBeChecked();
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// And nothing asked for the old filter afterwards either.
|
|
821
|
+
expect(mockGetListCalls).toHaveLength(3);
|
|
822
|
+
},
|
|
823
|
+
);
|
|
824
|
+
|
|
825
|
+
test.each(SURFACES)(
|
|
826
|
+
"$product sends Oldest first to the API and remembers it under one key",
|
|
827
|
+
async (surface: ResourceFeedSurface) => {
|
|
828
|
+
const resourceId: ObjectID = ObjectID.generate();
|
|
829
|
+
|
|
830
|
+
render(surface.mount(resourceId));
|
|
831
|
+
await waitForFeed(1);
|
|
832
|
+
|
|
833
|
+
// Nothing is remembered until the reader changes something.
|
|
834
|
+
expect(getStoredSortOrderKeys()).toEqual([]);
|
|
835
|
+
|
|
836
|
+
chooseSortOrder("Oldest first");
|
|
837
|
+
await waitForFeed(2);
|
|
838
|
+
|
|
839
|
+
const request: GetListRequest = mockGetListCalls[1]!;
|
|
840
|
+
|
|
841
|
+
// Reversing the loaded rows would show the newest ten upside down.
|
|
842
|
+
expect(request["sort"]).toEqual({ postedAt: SortOrder.Ascending });
|
|
843
|
+
expect(request["skip"]).toBe(0);
|
|
844
|
+
expect(request["limit"]).toBe(DEFAULT_LIMIT);
|
|
845
|
+
expect(Object.keys(getQuery(request))).toEqual([
|
|
846
|
+
surface.resourceIdColumn,
|
|
847
|
+
]);
|
|
848
|
+
|
|
849
|
+
expect(getFilterAndSortButton()).toHaveAccessibleDescription(
|
|
850
|
+
getExpectedSummary(surface, {
|
|
851
|
+
sortOrder: SortOrder.Ascending,
|
|
852
|
+
eventTypes: [],
|
|
853
|
+
}),
|
|
854
|
+
);
|
|
855
|
+
|
|
856
|
+
/*
|
|
857
|
+
* One key written, holding the new order. Which key it is does not
|
|
858
|
+
* matter here - that no two products share one is checked below, from
|
|
859
|
+
* the keys the pages are seen writing.
|
|
860
|
+
*/
|
|
861
|
+
const storedKeys: Array<string> = getStoredSortOrderKeys();
|
|
862
|
+
|
|
863
|
+
expect(storedKeys).toHaveLength(1);
|
|
864
|
+
expect(window.localStorage.getItem(storedKeys[0]!)).toBe(
|
|
865
|
+
SortOrder.Ascending,
|
|
866
|
+
);
|
|
867
|
+
|
|
868
|
+
// The next visit to this feed starts oldest first.
|
|
869
|
+
cleanup();
|
|
870
|
+
mockGetListCalls.length = 0;
|
|
871
|
+
|
|
872
|
+
render(surface.mount(ObjectID.generate()));
|
|
873
|
+
await waitForFeed(1);
|
|
874
|
+
|
|
875
|
+
expect(mockGetListCalls[0]?.["sort"]).toEqual({
|
|
876
|
+
postedAt: SortOrder.Ascending,
|
|
877
|
+
});
|
|
878
|
+
expect(
|
|
879
|
+
within(openFeedOptions()).getByRole("radio", { name: "Oldest first" }),
|
|
880
|
+
).toHaveAttribute("aria-checked", "true");
|
|
881
|
+
},
|
|
882
|
+
);
|
|
883
|
+
|
|
884
|
+
test("each product remembers its sort order under a key of its own, and both SLO feeds under the same one", async () => {
|
|
885
|
+
/*
|
|
886
|
+
* The keys are read off what each real surface writes when the reader
|
|
887
|
+
* picks Oldest first, not worked out from this file's table - a table
|
|
888
|
+
* can only agree with itself. Storage is emptied between surfaces, so
|
|
889
|
+
* each one's key is its own and not one left behind by the one before.
|
|
890
|
+
*/
|
|
891
|
+
interface WrittenSortOrder {
|
|
892
|
+
surface: ResourceFeedSurface;
|
|
893
|
+
keys: Array<string>;
|
|
894
|
+
values: Array<string | null>;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const written: Array<WrittenSortOrder> = [];
|
|
898
|
+
|
|
899
|
+
for (const surface of SURFACES) {
|
|
900
|
+
render(surface.mount(ObjectID.generate()));
|
|
901
|
+
await waitForFeed(1);
|
|
902
|
+
|
|
903
|
+
chooseSortOrder("Oldest first");
|
|
904
|
+
await waitForFeed(2);
|
|
905
|
+
|
|
906
|
+
const keys: Array<string> = getStoredSortOrderKeys();
|
|
907
|
+
|
|
908
|
+
written.push({
|
|
909
|
+
surface: surface,
|
|
910
|
+
keys: keys,
|
|
911
|
+
values: keys.map((key: string): string | null => {
|
|
912
|
+
return window.localStorage.getItem(key);
|
|
913
|
+
}),
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
window.localStorage.clear();
|
|
917
|
+
cleanup();
|
|
918
|
+
mockGetListCalls.length = 0;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// Every surface wrote exactly one key, holding the order just chosen.
|
|
922
|
+
expect(
|
|
923
|
+
written.map((entry: WrittenSortOrder) => {
|
|
924
|
+
return { product: entry.surface.product, values: entry.values };
|
|
925
|
+
}),
|
|
926
|
+
).toEqual(
|
|
927
|
+
SURFACES.map((surface: ResourceFeedSurface) => {
|
|
928
|
+
return { product: surface.product, values: [SortOrder.Ascending] };
|
|
929
|
+
}),
|
|
930
|
+
);
|
|
931
|
+
|
|
932
|
+
// Which products' feeds wrote each key.
|
|
933
|
+
const productsByKey: Map<string, Set<string>> = new Map<
|
|
934
|
+
string,
|
|
935
|
+
Set<string>
|
|
936
|
+
>();
|
|
937
|
+
|
|
938
|
+
for (const entry of written) {
|
|
939
|
+
const key: string = entry.keys[0]!;
|
|
940
|
+
|
|
941
|
+
productsByKey.set(
|
|
942
|
+
key,
|
|
943
|
+
new Set<string>([
|
|
944
|
+
...(productsByKey.get(key) || []),
|
|
945
|
+
entry.surface.feedProduct,
|
|
946
|
+
]),
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
type ByName = (a: Array<string>, b: Array<string>) => number;
|
|
951
|
+
|
|
952
|
+
const byName: ByName = (a: Array<string>, b: Array<string>): number => {
|
|
953
|
+
return a.join(",").localeCompare(b.join(","));
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
/*
|
|
957
|
+
* Exactly one product per key, and each product once. A key two products
|
|
958
|
+
* share would show up as one entry naming both: Oldest first on one of
|
|
959
|
+
* them would turn the other's feed over. A product with two keys would
|
|
960
|
+
* show up twice: the SLO Feed page and the SLO overview's feed, one feed
|
|
961
|
+
* shown in two places, disagreeing about which way time runs.
|
|
962
|
+
*/
|
|
963
|
+
expect(
|
|
964
|
+
Array.from(productsByKey.values())
|
|
965
|
+
.map((products: Set<string>): Array<string> => {
|
|
966
|
+
return Array.from(products).sort();
|
|
967
|
+
})
|
|
968
|
+
.sort(byName),
|
|
969
|
+
).toEqual(
|
|
970
|
+
FEED_PAGES.map((spec: FeedPageSpec): Array<string> => {
|
|
971
|
+
return [spec.product];
|
|
972
|
+
}).sort(byName),
|
|
973
|
+
);
|
|
974
|
+
|
|
975
|
+
// Spelled out for the one product shown twice.
|
|
976
|
+
type GetWrittenKey = (surface: ResourceFeedSurface) => string | undefined;
|
|
977
|
+
|
|
978
|
+
const getWrittenKey: GetWrittenKey = (
|
|
979
|
+
surface: ResourceFeedSurface,
|
|
980
|
+
): string | undefined => {
|
|
981
|
+
return written.find((entry: WrittenSortOrder) => {
|
|
982
|
+
return entry.surface.product === surface.product;
|
|
983
|
+
})?.keys[0];
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
expect(getWrittenKey(EMBEDDED_SLO_FEED)).toBeDefined();
|
|
987
|
+
expect(getWrittenKey(EMBEDDED_SLO_FEED)).toBe(
|
|
988
|
+
getWrittenKey(SLO_FEED_PAGE_SURFACE),
|
|
989
|
+
);
|
|
990
|
+
});
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
/*
|
|
994
|
+
* The SLO Feed page and the feed embedded on the SLO overview are the same
|
|
995
|
+
* feed shown in two places. The reader should not find them disagreeing
|
|
996
|
+
* about which way time runs, what can be filtered, or what an event looks
|
|
997
|
+
* like.
|
|
998
|
+
*/
|
|
999
|
+
describe("SLO feed - page and embedded feed", () => {
|
|
1000
|
+
type ReadSurfaceChecklist = (
|
|
1001
|
+
surface: ResourceFeedSurface,
|
|
1002
|
+
) => Promise<Array<ChecklistRow>>;
|
|
1003
|
+
|
|
1004
|
+
const readSurfaceChecklist: ReadSurfaceChecklist = async (
|
|
1005
|
+
surface: ResourceFeedSurface,
|
|
1006
|
+
): Promise<Array<ChecklistRow>> => {
|
|
1007
|
+
render(surface.mount(ObjectID.generate()));
|
|
1008
|
+
await waitForFeed(1);
|
|
1009
|
+
|
|
1010
|
+
const rows: Array<ChecklistRow> = toChecklistRows(
|
|
1011
|
+
readChecklist(openFeedOptions()),
|
|
1012
|
+
);
|
|
1013
|
+
|
|
1014
|
+
cleanup();
|
|
1015
|
+
mockGetListCalls.length = 0;
|
|
1016
|
+
|
|
1017
|
+
return rows;
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
test("offer the same event types, with the same labels and icons", async () => {
|
|
1021
|
+
const pageRows: Array<ChecklistRow> = await readSurfaceChecklist(
|
|
1022
|
+
SLO_FEED_PAGE_SURFACE,
|
|
1023
|
+
);
|
|
1024
|
+
const embeddedRows: Array<ChecklistRow> =
|
|
1025
|
+
await readSurfaceChecklist(EMBEDDED_SLO_FEED);
|
|
1026
|
+
|
|
1027
|
+
expect(pageRows).toHaveLength(
|
|
1028
|
+
Object.values(ServiceLevelObjectiveFeedEventType).length,
|
|
1029
|
+
);
|
|
1030
|
+
expect(embeddedRows).toEqual(pageRows);
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
test("share one remembered sort order", async () => {
|
|
1034
|
+
// Oldest first on the Feed page...
|
|
1035
|
+
render(SLO_FEED_PAGE_SURFACE.mount(ObjectID.generate()));
|
|
1036
|
+
await waitForFeed(1);
|
|
1037
|
+
chooseSortOrder("Oldest first");
|
|
1038
|
+
await waitForFeed(2);
|
|
1039
|
+
|
|
1040
|
+
expect(getStoredSortOrderKeys()).toHaveLength(1);
|
|
1041
|
+
|
|
1042
|
+
cleanup();
|
|
1043
|
+
mockGetListCalls.length = 0;
|
|
1044
|
+
|
|
1045
|
+
// ...is how the overview's feed opens.
|
|
1046
|
+
render(EMBEDDED_SLO_FEED.mount(ObjectID.generate()));
|
|
1047
|
+
await waitForFeed(1);
|
|
1048
|
+
|
|
1049
|
+
expect(mockGetListCalls[0]?.["sort"]).toEqual({
|
|
1050
|
+
postedAt: SortOrder.Ascending,
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
// And back to Newest first there is how the Feed page opens next.
|
|
1054
|
+
chooseSortOrder("Newest first");
|
|
1055
|
+
await waitForFeed(2);
|
|
1056
|
+
|
|
1057
|
+
expect(mockGetListCalls[1]?.["sort"]).toEqual({
|
|
1058
|
+
postedAt: SortOrder.Descending,
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
cleanup();
|
|
1062
|
+
mockGetListCalls.length = 0;
|
|
1063
|
+
|
|
1064
|
+
render(SLO_FEED_PAGE_SURFACE.mount(ObjectID.generate()));
|
|
1065
|
+
await waitForFeed(1);
|
|
1066
|
+
|
|
1067
|
+
expect(mockGetListCalls[0]?.["sort"]).toEqual({
|
|
1068
|
+
postedAt: SortOrder.Descending,
|
|
1069
|
+
});
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
test("show the SLO feed's own icons for the SLO's own event types", async () => {
|
|
1073
|
+
/*
|
|
1074
|
+
* StatusChanged, the burn rate events, the rule and monitor changes: the
|
|
1075
|
+
* shared suffix rules know none of them and would draw each as the same
|
|
1076
|
+
* anonymous dot. getSloFeedEventIcon is what tells them apart, and it is
|
|
1077
|
+
* what both checklists must show.
|
|
1078
|
+
*/
|
|
1079
|
+
const sloOwnEventTypes: Array<string> = Object.values(
|
|
1080
|
+
ServiceLevelObjectiveFeedEventType,
|
|
1081
|
+
).filter((eventType: string) => {
|
|
1082
|
+
return getSloFeedEventIcon(eventType) !== undefined;
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
expect(sloOwnEventTypes.length).toBeGreaterThan(0);
|
|
1086
|
+
|
|
1087
|
+
for (const eventType of sloOwnEventTypes) {
|
|
1088
|
+
const ownIcon: IconProp = getSloFeedEventIcon(eventType)!;
|
|
1089
|
+
|
|
1090
|
+
expect(
|
|
1091
|
+
resolveResourceFeedIcon({
|
|
1092
|
+
eventType: eventType,
|
|
1093
|
+
getIcon: getSloFeedEventIcon,
|
|
1094
|
+
}),
|
|
1095
|
+
).toBe(ownIcon);
|
|
1096
|
+
expect(ownIcon).not.toBe(getIconForEventType(eventType));
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
for (const surface of [SLO_FEED_PAGE_SURFACE, EMBEDDED_SLO_FEED]) {
|
|
1100
|
+
const rows: Array<ChecklistRow> = await readSurfaceChecklist(surface);
|
|
1101
|
+
|
|
1102
|
+
for (const eventType of sloOwnEventTypes) {
|
|
1103
|
+
const row: ChecklistRow | undefined = rows.find(
|
|
1104
|
+
(candidate: ChecklistRow) => {
|
|
1105
|
+
return candidate.label === getFeedEventTypeLabel(eventType);
|
|
1106
|
+
},
|
|
1107
|
+
);
|
|
1108
|
+
|
|
1109
|
+
expect(row).toBeDefined();
|
|
1110
|
+
expect(row!.icon).toBe(getSloFeedEventIcon(eventType));
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
type FindResourceFeedRenderers = (directory: string) => Array<string>;
|
|
1117
|
+
|
|
1118
|
+
const TYPESCRIPT_FILE: RegExp = /\.tsx?$/;
|
|
1119
|
+
|
|
1120
|
+
const RESOURCE_FEED_ELEMENT: RegExp = /<ResourceFeed[\s</>]/;
|
|
1121
|
+
|
|
1122
|
+
/*
|
|
1123
|
+
* Every file under the dashboard's src that renders <ResourceFeed>, relative
|
|
1124
|
+
* to src with forward slashes. Matches the JSX opening tag - with or without
|
|
1125
|
+
* a type argument - and not ResourceFeedModel or other longer names.
|
|
1126
|
+
*/
|
|
1127
|
+
const findResourceFeedRenderers: FindResourceFeedRenderers = (
|
|
1128
|
+
directory: string,
|
|
1129
|
+
): Array<string> => {
|
|
1130
|
+
const renderers: Array<string> = [];
|
|
1131
|
+
|
|
1132
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
1133
|
+
const entryPath: string = path.join(directory, entry.name);
|
|
1134
|
+
|
|
1135
|
+
if (entry.isDirectory()) {
|
|
1136
|
+
renderers.push(...findResourceFeedRenderers(entryPath));
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
if (!TYPESCRIPT_FILE.test(entry.name)) {
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
if (RESOURCE_FEED_ELEMENT.test(fs.readFileSync(entryPath, "utf8"))) {
|
|
1145
|
+
renderers.push(
|
|
1146
|
+
path.relative(DASHBOARD_SRC, entryPath).split(path.sep).join("/"),
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
return renderers;
|
|
1152
|
+
};
|
|
1153
|
+
|
|
1154
|
+
/*
|
|
1155
|
+
* The sweep above is only as good as its table. A new product's feed page
|
|
1156
|
+
* copies one of these and would otherwise ship untested - so the dashboard is
|
|
1157
|
+
* scanned, and whatever renders ResourceFeed has to be in the table.
|
|
1158
|
+
*/
|
|
1159
|
+
describe("Resource feed pages - the sweep covers every ResourceFeed", () => {
|
|
1160
|
+
test("every file that renders <ResourceFeed> is driven above, and nothing else is", () => {
|
|
1161
|
+
const renderers: Array<string> = findResourceFeedRenderers(DASHBOARD_SRC);
|
|
1162
|
+
const covered: Array<string> = SURFACES.map(
|
|
1163
|
+
(surface: ResourceFeedSurface) => {
|
|
1164
|
+
return surface.file;
|
|
1165
|
+
},
|
|
1166
|
+
);
|
|
1167
|
+
|
|
1168
|
+
expect(renderers.length).toBeGreaterThan(0);
|
|
1169
|
+
|
|
1170
|
+
// A renderer missing here is a feed nobody drives: add it to FEED_PAGES.
|
|
1171
|
+
expect(
|
|
1172
|
+
renderers.filter((file: string) => {
|
|
1173
|
+
return !covered.includes(file);
|
|
1174
|
+
}),
|
|
1175
|
+
).toEqual([]);
|
|
1176
|
+
|
|
1177
|
+
// A row missing there is a file that no longer renders ResourceFeed.
|
|
1178
|
+
expect(
|
|
1179
|
+
covered.filter((file: string) => {
|
|
1180
|
+
return !renderers.includes(file);
|
|
1181
|
+
}),
|
|
1182
|
+
).toEqual([]);
|
|
1183
|
+
|
|
1184
|
+
expect(new Set(covered).size).toBe(covered.length);
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
test.each(SURFACES)(
|
|
1188
|
+
"$product is the component $file exports",
|
|
1189
|
+
async (surface: ResourceFeedSurface) => {
|
|
1190
|
+
/*
|
|
1191
|
+
* The row's file is what the scan matched, and the row's component is
|
|
1192
|
+
* what was rendered - so the two must be the same module, or a new page
|
|
1193
|
+
* could be "covered" by a row that still renders an old one.
|
|
1194
|
+
*/
|
|
1195
|
+
const feedModule: { default: unknown } = await import(
|
|
1196
|
+
path.join(DASHBOARD_SRC, surface.file)
|
|
1197
|
+
);
|
|
1198
|
+
|
|
1199
|
+
expect(feedModule.default).toBe(surface.component);
|
|
1200
|
+
},
|
|
1201
|
+
);
|
|
1202
|
+
});
|