@oneuptime/common 12.0.7 → 12.0.9
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/AnalyticsModels/MetricItemAggMV1mByK8sCluster.ts +1 -1
- package/Models/AnalyticsModels/MetricItemAggMV1mByService.ts +1 -1
- package/Models/DatabaseModels/AIConversation.ts +32 -0
- package/Models/DatabaseModels/AIConversationMessage.ts +41 -0
- package/Models/DatabaseModels/AlertEpisodeMember.ts +27 -0
- package/Models/DatabaseModels/IncidentEpisodeMember.ts +28 -0
- package/Models/DatabaseModels/Index.ts +12 -4
- package/Models/DatabaseModels/{TelemetryEntity.ts → InventoryItem.ts} +184 -9
- package/Models/DatabaseModels/InventoryItemCustomField.ts +434 -0
- package/Models/DatabaseModels/{TelemetryEntityRelationship.ts → InventoryItemRelationship.ts} +33 -7
- package/Models/DatabaseModels/NetworkDevice.ts +141 -0
- package/Models/DatabaseModels/NetworkDeviceLink.ts +699 -0
- package/Models/DatabaseModels/NetworkDeviceLinkRule.ts +467 -0
- package/Models/DatabaseModels/NetworkTopologySuppression.ts +429 -0
- package/Models/DatabaseModels/OnCallDutyPolicyFeed.ts +9 -0
- package/Models/DatabaseModels/Project.ts +78 -0
- package/Models/DatabaseModels/UserCall.ts +42 -0
- package/Models/DatabaseModels/UserEmail.ts +44 -0
- package/Models/DatabaseModels/UserNotificationRule.ts +425 -55
- package/Models/DatabaseModels/UserOnCallLogTimeline.ts +24 -2
- package/Models/DatabaseModels/UserPush.ts +49 -0
- package/Models/DatabaseModels/UserSMS.ts +43 -0
- package/Models/DatabaseModels/UserTelegram.ts +59 -0
- package/Models/DatabaseModels/UserWebhook.ts +52 -0
- package/Models/DatabaseModels/UserWhatsApp.ts +41 -0
- package/Models/DatabaseModels/WorkflowLog.ts +38 -0
- package/Models/DatabaseModels/WorkflowVariable.ts +12 -0
- package/Server/API/AIChatAPI.ts +315 -1
- package/Server/API/DashboardAPI.ts +217 -1
- package/Server/API/OnCallReadinessAPI.ts +841 -0
- package/Server/API/TeamComplianceAPI.ts +69 -17
- package/Server/API/TelemetryAPI.ts +220 -12
- package/Server/API/UserAPI.ts +16 -1
- package/Server/EnvironmentConfig.ts +52 -0
- package/Server/Infrastructure/Postgres/DataSourceOptions.ts +22 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.ts +4 -4
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.ts +5 -5
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.ts +41 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.ts +17 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.ts +35 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.ts +82 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.ts +91 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.ts +47 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.ts +255 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.ts +107 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.ts +90 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.ts +39 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.ts +33 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.ts +59 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +24 -0
- package/Server/Infrastructure/Queue.ts +78 -13
- package/Server/Middleware/MasterAdminAuthorization.ts +11 -6
- package/Server/Middleware/PublicDashboardRateLimit.ts +593 -0
- package/Server/Services/AIService.ts +7 -0
- package/Server/Services/AlertEpisodeStateTimelineService.ts +29 -0
- package/Server/Services/AlertSeverityService.ts +63 -0
- package/Server/Services/DashboardService.ts +9 -10
- package/Server/Services/DatabaseService.ts +32 -2
- package/Server/Services/IncidentEpisodeStateTimelineService.ts +29 -0
- package/Server/Services/IncidentSeverityService.ts +76 -0
- package/Server/Services/Index.ts +12 -4
- package/Server/Services/InventoryItemCustomFieldService.ts +9 -0
- package/Server/Services/{TelemetryEntityRelationshipService.ts → InventoryItemRelationshipService.ts} +7 -4
- package/Server/Services/{TelemetryEntityService.ts → InventoryItemService.ts} +203 -21
- package/Server/Services/LogAggregationService.ts +45 -8
- package/Server/Services/MetricAggregationService.ts +121 -0
- package/Server/Services/MetricService.ts +7 -7
- package/Server/Services/NetworkDeviceLinkRuleService.ts +10 -0
- package/Server/Services/NetworkDeviceLinkService.ts +84 -0
- package/Server/Services/NetworkDeviceService.ts +140 -0
- package/Server/Services/NetworkSiteService.ts +77 -25
- package/Server/Services/NetworkTopologySuppressionService.ts +84 -0
- package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +41 -29
- package/Server/Services/OnCallDutyPolicyExecutionLogService.ts +8 -0
- package/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.ts +62 -13
- package/Server/Services/OnCallDutyPolicyScheduleService.ts +61 -1
- package/Server/Services/OnCallNotificationAlertingService.ts +742 -0
- package/Server/Services/OnCallReadinessService.ts +2803 -0
- package/Server/Services/OnCallSetupReminderService.ts +955 -0
- package/Server/Services/ProfileAggregationService.ts +123 -0
- package/Server/Services/StatusPageService.ts +9 -10
- package/Server/Services/TeamComplianceService.ts +429 -252
- package/Server/Services/UserCallService.ts +26 -1
- package/Server/Services/UserEmailService.ts +26 -1
- package/Server/Services/UserNotificationRuleAdminService.ts +1183 -0
- package/Server/Services/UserNotificationRuleService.ts +3812 -333
- package/Server/Services/UserOnCallLogService.ts +561 -48
- package/Server/Services/UserPushService.ts +29 -0
- package/Server/Services/UserService.ts +11 -0
- package/Server/Services/UserSmsService.ts +26 -1
- package/Server/Services/UserTelegramService.ts +24 -1
- package/Server/Services/UserWebhookService.ts +28 -1
- package/Server/Services/UserWhatsAppService.ts +24 -1
- package/Server/Types/Database/Permissions/BasePermission.ts +19 -0
- package/Server/Types/Database/Permissions/CreatePermission.ts +164 -0
- package/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.ts +340 -0
- package/Server/Types/Database/Permissions/QueryPermission.ts +48 -0
- package/Server/Types/Database/Permissions/TenantPermission.ts +8 -1
- package/Server/Types/Workflow/Components/API/Delete.ts +1 -1
- package/Server/Types/Workflow/Components/API/Get.ts +1 -1
- package/Server/Types/Workflow/Components/API/Patch.ts +1 -1
- package/Server/Types/Workflow/Components/API/Post.ts +1 -1
- package/Server/Types/Workflow/Components/API/Put.ts +1 -1
- package/Server/Types/Workflow/Components/API/Utils.ts +44 -1
- package/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.ts +29 -5
- package/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.ts +18 -10
- package/Server/Types/Workflow/Components/BaseModel/ModelArguments.ts +55 -0
- package/Server/Types/Workflow/Components/Conditions/IfElse.ts +3 -17
- package/Server/Types/Workflow/Components/Email.ts +25 -7
- package/Server/Types/Workflow/Components/JavaScript.ts +10 -3
- package/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.ts +1 -1
- package/Server/Types/Workflow/TriggerCode.ts +12 -0
- package/Server/Types/Workflow/Workflow.ts +5 -0
- package/Server/Utils/AI/Chat/ChatAgentRunner.ts +643 -48
- package/Server/Utils/AI/Chat/ObservabilityAssistant.ts +32 -3
- package/Server/Utils/AI/Chat/ObservabilityChatPrompt.ts +20 -6
- package/Server/Utils/AI/SRE/AIInvestigationEngine.ts +7 -0
- package/Server/Utils/AI/Toolbox/AIActionTools.ts +2 -2
- package/Server/Utils/AI/Toolbox/AIMetaTools.ts +863 -0
- package/Server/Utils/AI/Toolbox/AlertTools.ts +177 -15
- package/Server/Utils/AI/Toolbox/IncidentTools.ts +191 -10
- package/Server/Utils/AI/Toolbox/Index.ts +48 -0
- package/Server/Utils/AI/Toolbox/MonitorTools.ts +298 -11
- package/Server/Utils/AI/Toolbox/NoteWriteTools.ts +295 -0
- package/Server/Utils/AI/Toolbox/OnCallTools.ts +1246 -0
- package/Server/Utils/AI/Toolbox/RunbookTools.ts +424 -0
- package/Server/Utils/AI/Toolbox/SloTools.ts +456 -0
- package/Server/Utils/AI/Toolbox/StatusPageTools.ts +559 -0
- package/Server/Utils/AI/Toolbox/TeamTools.ts +327 -0
- package/Server/Utils/AI/Toolbox/TimelineTools.ts +615 -0
- package/Server/Utils/AI/Toolbox/WorkflowProbeTools.ts +664 -0
- package/Server/Utils/ClientIp.ts +221 -0
- package/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.ts +47 -0
- package/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.ts +163 -0
- package/Server/Utils/Dashboard/PublicDashboardSloWidget.ts +147 -0
- package/Server/Utils/Express.ts +12 -17
- package/Server/Utils/LLM/LLMService.ts +85 -8
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +204 -10
- package/Server/Utils/SSRFProtection.ts +98 -23
- package/Server/Utils/StartServer.ts +12 -3
- package/Server/Utils/Telemetry/EntityRegistry.ts +205 -18
- package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +689 -0
- package/Server/Utils/Telemetry/TelemetryEntity.ts +160 -52
- package/Server/Utils/VM/VMAPI.ts +56 -8
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +7 -3
- package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +2146 -0
- package/Tests/App/Dashboard/CreateWorkflowModal.test.tsx +561 -0
- package/Tests/App/Dashboard/EscalationRuleReadiness.test.tsx +2470 -0
- package/Tests/App/Dashboard/MonitorCriteriaAttributeFilter.test.tsx +574 -0
- package/Tests/App/Dashboard/OnCallPreventionGuards.test.tsx +1897 -0
- package/Tests/App/Dashboard/OnCallReadinessSurfaces.test.tsx +3606 -0
- package/Tests/App/Dashboard/OnCallRulesDeleteGuard.test.tsx +784 -0
- package/Tests/App/Dashboard/OnCallRulesTable.test.tsx +1119 -0
- package/Tests/App/Dashboard/SloWidgetFetching.test.tsx +531 -0
- package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +1312 -0
- package/Tests/App/Dashboard/UserSettingsSetupChecklistPage.test.tsx +1390 -0
- package/Tests/Models/InventoryItemModel.test.ts +174 -0
- package/Tests/Models/InventoryItemNaming.test.ts +302 -0
- package/Tests/Server/API/AIChatCancelAndFeedback.test.ts +437 -0
- package/Tests/Server/API/DashboardPublicRateLimit.test.ts +659 -0
- package/Tests/Server/API/DashboardPublicResourceListAPI.test.ts +18 -0
- package/Tests/Server/API/DashboardPublicSloAPI.test.ts +880 -0
- package/Tests/Server/API/Helpers.ts +24 -15
- package/Tests/Server/API/OnCallReadinessAPI.test.ts +2680 -0
- package/Tests/Server/API/OnCallSetupReminderAPI.test.ts +915 -0
- package/Tests/Server/API/UserProjectsAPI.test.ts +478 -4
- package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +533 -0
- package/Tests/Server/Infrastructure/Postgres/InventoryItemArchiveMigration.test.ts +213 -0
- package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +432 -0
- package/Tests/Server/Infrastructure/Queue.test.ts +293 -0
- package/Tests/Server/Middleware/PublicDashboardRateLimit.test.ts +1645 -0
- package/Tests/Server/Services/AdminRuleEditGuards.test.ts +2848 -0
- package/Tests/Server/Services/DeliverNotificationForRuleExtraction.test.ts +1393 -0
- package/Tests/Server/Services/EpisodeRuleSeverityRepair.test.ts +1802 -0
- package/Tests/Server/Services/EpisodeStateTimelineNote.test.ts +304 -0
- package/Tests/Server/Services/InventoryItemDisplayName.test.ts +339 -0
- package/Tests/Server/Services/InventoryItemManualCreate.test.ts +248 -0
- package/Tests/Server/Services/IpAllowlistSpoofing.test.ts +450 -0
- package/Tests/Server/Services/LogAggregationService.test.ts +235 -1
- package/Tests/Server/Services/MetricAggregationService.test.ts +231 -0
- package/Tests/Server/Services/MetricEntityMVKeyParity.test.ts +80 -29
- package/Tests/Server/Services/MetricServiceAggregate.test.ts +30 -30
- package/Tests/Server/Services/NetworkSiteService.test.ts +18 -3
- package/Tests/Server/Services/NotificationChannelEventCoverage.test.ts +1728 -0
- package/Tests/Server/Services/NotificationDeletionImpact.test.ts +2402 -0
- package/Tests/Server/Services/OnCallDutyPolicyExecutionLogTimelineGapFeed.test.ts +394 -0
- package/Tests/Server/Services/OnCallNotificationFallback.test.ts +1744 -0
- package/Tests/Server/Services/OnCallReadinessService.test.ts +4295 -0
- package/Tests/Server/Services/OnCallSetupReminder.test.ts +1272 -0
- package/Tests/Server/Services/OnCallWeeklyReadinessDigest.test.ts +1021 -0
- package/Tests/Server/Services/ProfileAggregationService.test.ts +296 -0
- package/Tests/Server/Services/SeverityCreationRuleBackfill.test.ts +1536 -0
- package/Tests/Server/Services/SeverityRuleBackfill.test.ts +1818 -0
- package/Tests/Server/Services/TeamComplianceServiceBehaviour.test.ts +1845 -0
- package/Tests/Server/Services/UserNotificationRuleAdminGuards.test.ts +1394 -0
- package/Tests/Server/Services/UserNotificationRuleDefaultCreation.test.ts +1166 -0
- package/Tests/Server/Services/UserNotificationRuleExecuteItem.test.ts +1468 -0
- package/Tests/Server/Services/UserOnCallLogNoNotificationRules.test.ts +1457 -0
- package/Tests/Server/Types/Database/Permissions/AdminNotificationRuleAccess.test.ts +1546 -0
- package/Tests/Server/Types/Database/Permissions/CreateOwnershipScoping.test.ts +529 -0
- package/Tests/Server/Types/Database/Permissions/OwnerOnlyColumns.test.ts +1219 -0
- package/Tests/Server/Types/Database/Permissions/UserNotificationRuleScoping.test.ts +1089 -0
- package/Tests/Server/Types/Workflow/Components/ApiComponentErrorPort.test.ts +2 -1
- package/Tests/Server/Types/Workflow/Components/ApiComponentHeaders.test.ts +192 -0
- package/Tests/Server/Types/Workflow/Components/BaseModelDatabaseComponents.test.ts +190 -0
- package/Tests/Server/Types/Workflow/Components/ChatWebhookComponents.test.ts +44 -14
- package/Tests/Server/Types/Workflow/Components/Email.test.ts +151 -0
- package/Tests/Server/Types/Workflow/Components/IfElse.test.ts +98 -0
- package/Tests/Server/Types/Workflow/Components/JavaScript.test.ts +51 -0
- package/Tests/Server/Utils/AI/AIMetaTools.test.ts +586 -0
- package/Tests/Server/Utils/AI/AlertMonitorFilters.test.ts +582 -0
- package/Tests/Server/Utils/AI/ChatAgentRunner.test.ts +726 -0
- package/Tests/Server/Utils/AI/IncidentToolsFilters.test.ts +315 -0
- package/Tests/Server/Utils/AI/LLMServiceStopReason.test.ts +314 -0
- package/Tests/Server/Utils/AI/LLMServiceToolCalling.test.ts +26 -3
- package/Tests/Server/Utils/AI/NoteWriteTools.test.ts +268 -0
- package/Tests/Server/Utils/AI/ObservabilityChatPrompt.test.ts +169 -0
- package/Tests/Server/Utils/AI/OnCallTools.test.ts +664 -0
- package/Tests/Server/Utils/AI/RunbookTools.test.ts +325 -0
- package/Tests/Server/Utils/AI/SloTools.test.ts +306 -0
- package/Tests/Server/Utils/AI/StatusPageTools.test.ts +391 -0
- package/Tests/Server/Utils/AI/TeamTools.test.ts +257 -0
- package/Tests/Server/Utils/AI/TimelineTools.test.ts +472 -0
- package/Tests/Server/Utils/AI/WorkflowProbeTools.test.ts +428 -0
- package/Tests/Server/Utils/AnalyticsDatabase/QuerySettingsHelper.test.ts +152 -0
- package/Tests/Server/Utils/ClientIp.test.ts +438 -0
- package/Tests/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.test.ts +171 -0
- package/Tests/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.test.ts +383 -0
- package/Tests/Server/Utils/EntityRegistryRowFence.test.ts +23 -25
- package/Tests/Server/Utils/MicrosoftTeamsWebhookUrlValidation.test.ts +6 -0
- package/Tests/Server/Utils/Monitor/Criteria/DnssecMonitorCriteria.test.ts +307 -0
- package/Tests/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.test.ts +468 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorTelemetryDeepLinks.test.ts +460 -0
- package/Tests/Server/Utils/ResponseRateLimitStatusCodes.test.ts +137 -0
- package/Tests/Server/Utils/SSRFProtectionBypasses.test.ts +40 -8
- package/Tests/Server/Utils/SSRFProtectionUserInfo.test.ts +351 -0
- package/Tests/Server/Utils/Telemetry/EntityRegistryRetirement.test.ts +531 -0
- package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +322 -0
- package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +356 -39
- package/Tests/Server/Utils/VM/VMAPISubstitution.test.ts +243 -0
- package/Tests/Types/IP/IP.test.ts +263 -0
- package/Tests/Types/IP/IPWhitelist.test.ts +197 -0
- package/Tests/Types/IP/IPv6.test.ts +12 -1
- package/Tests/Types/Monitor/SnmpOid.test.ts +64 -0
- package/Tests/Types/NetworkDevice/NetworkDeviceMonitoringMethod.test.ts +110 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilMergeAudit.test.ts +106 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilMergeDifferential.test.ts +511 -0
- package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageEndToEnd.test.ts +489 -0
- package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageGapTolerance.test.ts +479 -0
- package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageState.test.ts +822 -0
- package/Tests/Types/SerializableObjectDictionaryImportCycle.test.ts +197 -0
- package/Tests/Types/Telemetry/EntityTypeGroups.test.ts +140 -0
- package/Tests/Types/Workflow/BaseModelComponents.test.ts +429 -0
- package/Tests/Types/Workflow/Components/BaseModel.test.ts +225 -0
- package/Tests/Types/Workflow/IntegrationCredentialMetadata.test.ts +100 -0
- package/Tests/Types/Workflow/StepTrace.test.ts +235 -0
- package/Tests/Types/Workflow/TemplateSyntax.test.ts +491 -0
- package/Tests/Types/Workflow/Templates.test.ts +1218 -0
- package/Tests/UI/Components/ActiveFilterChipsOpenRoute.test.tsx +82 -0
- package/Tests/UI/Components/ComponentsModal.test.tsx +564 -7
- package/Tests/UI/Components/CustomTimeRangeModal.test.tsx +459 -0
- package/Tests/UI/Components/DictionaryAttributeFilterRow.test.tsx +477 -0
- package/Tests/UI/Components/Forms/AnchoredFieldPopupKeyboard.test.tsx +382 -0
- package/Tests/UI/Components/Forms/ColorPickerPicking.test.tsx +569 -0
- package/Tests/UI/Components/Forms/ValidationJSON.test.ts +241 -0
- package/Tests/UI/Components/KeyboardShortcut.test.tsx +95 -0
- package/Tests/UI/Components/LogDetailsPanelCrossSignal.test.tsx +448 -0
- package/Tests/UI/Components/LogTimeRangePicker.test.tsx +42 -0
- package/Tests/UI/Components/LogsTableCrossLinks.test.tsx +263 -0
- package/Tests/UI/Components/ModalPortalDismissal.test.tsx +90 -0
- package/Tests/UI/Components/PendingProjectInvitations.test.tsx +913 -0
- package/Tests/UI/Components/SimpleLogViewer.test.tsx +228 -0
- package/Tests/UI/Components/TableRowSelectability.test.tsx +312 -0
- package/Tests/UI/Components/TelemetryTimeRangePicker.test.tsx +49 -7
- package/Tests/UI/Components/TimeRangePickerDropdown.test.tsx +325 -0
- package/Tests/UI/Components/Workflow/GraphLint.test.ts +893 -0
- package/Tests/UI/Components/Workflow/GraphLintSummary.test.ts +755 -0
- package/Tests/UI/Components/Workflow/ModelColumnEditor.test.ts +522 -0
- package/Tests/UI/Components/Workflow/ModelColumnEditorServerContract.test.ts +193 -0
- package/Tests/UI/Components/Workflow/ModelSchema.test.ts +388 -0
- package/Tests/UI/Components/Workflow/RunStatusWatcher.test.ts +210 -0
- package/Tests/UI/Components/Workflow/StepTraceViewer.test.tsx +256 -0
- package/Tests/UI/Components/Workflow/UseRunWatch.test.tsx +665 -0
- package/Tests/UI/Components/Workflow/Utils.test.ts +192 -0
- package/Tests/UI/Components/Workflow/WorkflowIssuesModal.test.tsx +485 -0
- package/Tests/UI/Components/Workflow/WorkflowLogModal.test.tsx +478 -0
- package/Tests/UI/Components/Workflow/WorkflowStatusBar.test.tsx +379 -0
- package/Tests/UI/EsbuildConfig.test.ts +607 -0
- package/Tests/UI/Monitor/MonitorStepCriteriaView.test.tsx +432 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +5 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +11 -4
- package/Tests/UI/Utils/ModelAPICreateMiscData.test.ts +94 -0
- package/Tests/UI/Utils/Platform.test.ts +147 -0
- package/Tests/UI/Utils/ProjectInvitationDisplay.test.ts +357 -0
- package/Tests/Utils/Monitor/NetworkDeviceLinkRuleUtil.test.ts +198 -0
- package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +514 -0
- package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +727 -0
- package/Tests/Utils/Schema/AnalyticsModelSchema.test.ts +469 -0
- package/Tests/Utils/Schema/ModelSchema.test.ts +268 -0
- package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +698 -0
- package/Tests/Utils/Telemetry/EntityKeyNonTelemetry.test.ts +193 -0
- package/Tests/__mocks__/bullmq.js +55 -0
- package/Types/AI/AIChatMessageStatus.ts +8 -1
- package/Types/AI/AIChatTypes.ts +12 -0
- package/Types/Database/AccessControl/OwnerOnlyColumn.ts +88 -0
- package/Types/Exception/ExceptionCode.ts +2 -0
- package/Types/Exception/ServiceUnavailableException.ts +8 -0
- package/Types/Exception/TooManyRequestsException.ts +8 -0
- package/Types/IP/IP.ts +93 -47
- package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +80 -3
- package/Types/NetworkDevice/NetworkDeviceMonitoringMethod.ts +55 -0
- package/Types/OnCallDutyPolicy/Layer.ts +203 -149
- package/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.ts +13 -0
- package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +155 -10
- package/Types/Permission.ts +193 -0
- package/Types/SerializableObjectDictionary.ts +133 -39
- package/Types/Telemetry/EntityRelationshipType.ts +1 -1
- package/Types/Telemetry/EntitySource.ts +40 -0
- package/Types/Telemetry/EntityType.ts +28 -1
- package/Types/Telemetry/EntityTypeGroups.ts +65 -0
- package/Types/Workflow/Component.ts +29 -0
- package/Types/Workflow/Components/API.ts +35 -0
- package/Types/Workflow/Components/BaseModel.ts +77 -27
- package/Types/Workflow/Components/Discord.ts +1 -0
- package/Types/Workflow/Components/Email.ts +12 -3
- package/Types/Workflow/Components/JavaScript.ts +7 -0
- package/Types/Workflow/Components/MicrosoftTeams.ts +3 -2
- package/Types/Workflow/Components/Slack.ts +1 -0
- package/Types/Workflow/Components/Telegram.ts +1 -0
- package/Types/Workflow/StepTrace.ts +181 -0
- package/Types/Workflow/TemplateSyntax.ts +543 -0
- package/Types/Workflow/Templates.ts +2368 -0
- package/UI/Components/Calendar/Calendar.css +43 -0
- package/UI/Components/Calendar/Calendar.tsx +8 -0
- package/UI/Components/Card/Card.tsx +2 -2
- package/UI/Components/Checkbox/Checkbox.tsx +16 -0
- package/UI/Components/Date/CustomTimeRangeModal.tsx +278 -0
- package/UI/Components/Date/TimeRangePickerDropdown.tsx +250 -0
- package/UI/Components/Dictionary/Dictionary.tsx +71 -15
- package/UI/Components/FormModal/BasicFormModal.tsx +2 -1
- package/UI/Components/Forms/Fields/ColorPicker.tsx +66 -10
- package/UI/Components/Forms/Fields/IconPicker.tsx +48 -10
- package/UI/Components/Forms/Types/Field.ts +7 -0
- package/UI/Components/Forms/Validation.ts +63 -1
- package/UI/Components/Header/HeaderIconDropdownButton.tsx +53 -3
- package/UI/Components/Input/Input.tsx +32 -3
- package/UI/Components/KeyboardShortcut/KeyboardKey.ts +185 -0
- package/UI/Components/KeyboardShortcut/KeyboardShortcut.tsx +87 -0
- package/UI/Components/LogsViewer/LogsViewer.tsx +28 -0
- package/UI/Components/LogsViewer/components/ActiveFilterChips.tsx +31 -0
- package/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.tsx +18 -18
- package/UI/Components/LogsViewer/components/LogDetailsPanel.tsx +363 -14
- package/UI/Components/LogsViewer/components/LogTimeRangePicker.tsx +11 -216
- package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +11 -0
- package/UI/Components/LogsViewer/components/LogsTable.tsx +155 -12
- package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +29 -0
- package/UI/Components/LogsViewer/types.ts +23 -0
- package/UI/Components/Markdown.tsx/MarkdownEditor.tsx +9 -2
- package/UI/Components/Modal/Modal.tsx +37 -4
- package/UI/Components/Navbar/NavBarMenuModal.tsx +15 -30
- package/UI/Components/ProjectInvitations/PendingProjectInvitations.tsx +442 -0
- package/UI/Components/SimpleLogViewer/SimpleLogViewer.tsx +23 -1
- package/UI/Components/Table/Table.tsx +49 -16
- package/UI/Components/Table/TableBody.tsx +53 -28
- package/UI/Components/Table/TableHeader.tsx +13 -0
- package/UI/Components/Table/TableRow.tsx +58 -26
- package/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.tsx +11 -210
- package/UI/Components/Workflow/ArgumentsForm.tsx +341 -8
- package/UI/Components/Workflow/Component.tsx +28 -26
- package/UI/Components/Workflow/ComponentReturnValueViewer.tsx +26 -0
- package/UI/Components/Workflow/ComponentSettingsModal.tsx +79 -9
- package/UI/Components/Workflow/ComponentValuePickerModal.tsx +135 -7
- package/UI/Components/Workflow/ComponentsModal.tsx +116 -22
- package/UI/Components/Workflow/DocumentationViewer.tsx +59 -9
- package/UI/Components/Workflow/GraphLint.ts +628 -0
- package/UI/Components/Workflow/GraphLintSummary.ts +390 -0
- package/UI/Components/Workflow/ModelColumnEditor.tsx +528 -0
- package/UI/Components/Workflow/ModelSchema.ts +278 -0
- package/UI/Components/Workflow/RunForm.tsx +41 -7
- package/UI/Components/Workflow/RunStatusWatcher.ts +122 -0
- package/UI/Components/Workflow/StepTraceViewer.tsx +186 -0
- package/UI/Components/Workflow/UseRunWatch.ts +212 -0
- package/UI/Components/Workflow/Utils.ts +93 -1
- package/UI/Components/Workflow/VariableModal.tsx +6 -2
- package/UI/Components/Workflow/Workflow.tsx +126 -7
- package/UI/Components/Workflow/WorkflowIssuesModal.tsx +255 -0
- package/UI/Components/Workflow/WorkflowLogModal.tsx +128 -0
- package/UI/Components/Workflow/WorkflowStatusBar.tsx +224 -0
- package/UI/Types/LayeredDismissal.ts +31 -0
- package/UI/Types/UseAnchoredFieldPopup.ts +99 -0
- package/UI/Utils/AIChatExport/ConversationMarkdown.ts +10 -0
- package/UI/Utils/ModelAPI/ModelAPI.ts +7 -1
- package/UI/Utils/Platform.ts +149 -0
- package/UI/Utils/ProjectInvitationDisplay.ts +118 -0
- package/UI/esbuild-config.js +22 -1
- package/Utils/Monitor/NetworkDeviceLinkRuleUtil.ts +187 -0
- package/Utils/Monitor/NetworkDeviceRoleUtil.ts +533 -0
- package/Utils/Monitor/NetworkTopologyUtil.ts +917 -155
- package/Utils/Telemetry/CrossSignalScope.ts +502 -0
- package/Utils/Telemetry/EntityKey.ts +71 -5
- package/Utils/Telemetry/EntityRelationship.ts +1 -1
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.js +1 -1
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByService.js +1 -1
- package/build/dist/Models/DatabaseModels/AIConversation.js +32 -0
- package/build/dist/Models/DatabaseModels/AIConversation.js.map +1 -1
- package/build/dist/Models/DatabaseModels/AIConversationMessage.js +42 -0
- package/build/dist/Models/DatabaseModels/AIConversationMessage.js.map +1 -1
- package/build/dist/Models/DatabaseModels/AlertEpisodeMember.js +28 -0
- package/build/dist/Models/DatabaseModels/AlertEpisodeMember.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentEpisodeMember.js +29 -0
- package/build/dist/Models/DatabaseModels/IncidentEpisodeMember.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Index.js +12 -4
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/{TelemetryEntity.js → InventoryItem.js} +212 -29
- package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -0
- package/build/dist/Models/DatabaseModels/InventoryItemCustomField.js +454 -0
- package/build/dist/Models/DatabaseModels/InventoryItemCustomField.js.map +1 -0
- package/build/dist/Models/DatabaseModels/{TelemetryEntityRelationship.js → InventoryItemRelationship.js} +52 -25
- package/build/dist/Models/DatabaseModels/InventoryItemRelationship.js.map +1 -0
- package/build/dist/Models/DatabaseModels/NetworkDevice.js +141 -0
- package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +719 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js +475 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js.map +1 -0
- package/build/dist/Models/DatabaseModels/NetworkTopologySuppression.js +446 -0
- package/build/dist/Models/DatabaseModels/NetworkTopologySuppression.js.map +1 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyFeed.js +9 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyFeed.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Project.js +80 -0
- package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserCall.js +46 -2
- package/build/dist/Models/DatabaseModels/UserCall.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserEmail.js +48 -2
- package/build/dist/Models/DatabaseModels/UserEmail.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserNotificationRule.js +424 -55
- package/build/dist/Models/DatabaseModels/UserNotificationRule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js +24 -2
- package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserPush.js +51 -1
- package/build/dist/Models/DatabaseModels/UserPush.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserSMS.js +47 -2
- package/build/dist/Models/DatabaseModels/UserSMS.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserTelegram.js +65 -3
- package/build/dist/Models/DatabaseModels/UserTelegram.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserWebhook.js +56 -2
- package/build/dist/Models/DatabaseModels/UserWebhook.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserWhatsApp.js +45 -2
- package/build/dist/Models/DatabaseModels/UserWhatsApp.js.map +1 -1
- package/build/dist/Models/DatabaseModels/WorkflowLog.js +39 -0
- package/build/dist/Models/DatabaseModels/WorkflowLog.js.map +1 -1
- package/build/dist/Models/DatabaseModels/WorkflowVariable.js +12 -0
- package/build/dist/Models/DatabaseModels/WorkflowVariable.js.map +1 -1
- package/build/dist/Server/API/AIChatAPI.js +237 -1
- package/build/dist/Server/API/AIChatAPI.js.map +1 -1
- package/build/dist/Server/API/DashboardAPI.js +165 -13
- package/build/dist/Server/API/DashboardAPI.js.map +1 -1
- package/build/dist/Server/API/OnCallReadinessAPI.js +599 -0
- package/build/dist/Server/API/OnCallReadinessAPI.js.map +1 -0
- package/build/dist/Server/API/TeamComplianceAPI.js +68 -9
- package/build/dist/Server/API/TeamComplianceAPI.js.map +1 -1
- package/build/dist/Server/API/TelemetryAPI.js +129 -18
- package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
- package/build/dist/Server/API/UserAPI.js +16 -1
- package/build/dist/Server/API/UserAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +45 -0
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js +22 -0
- package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js +4 -4
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.js +20 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.js +12 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.js +18 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.js +39 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.js +38 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.js +22 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.js +150 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.js +57 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.js +69 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.js +32 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.js +26 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.js +48 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +24 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Infrastructure/Queue.js +72 -13
- package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
- package/build/dist/Server/Middleware/MasterAdminAuthorization.js +11 -6
- package/build/dist/Server/Middleware/MasterAdminAuthorization.js.map +1 -1
- package/build/dist/Server/Middleware/PublicDashboardRateLimit.js +399 -0
- package/build/dist/Server/Middleware/PublicDashboardRateLimit.js.map +1 -0
- package/build/dist/Server/Services/AIService.js +1 -0
- package/build/dist/Server/Services/AIService.js.map +1 -1
- package/build/dist/Server/Services/AlertEpisodeStateTimelineService.js +24 -3
- package/build/dist/Server/Services/AlertEpisodeStateTimelineService.js.map +1 -1
- package/build/dist/Server/Services/AlertSeverityService.js +54 -0
- package/build/dist/Server/Services/AlertSeverityService.js.map +1 -1
- package/build/dist/Server/Services/DashboardService.js +10 -9
- package/build/dist/Server/Services/DashboardService.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +24 -2
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js +24 -3
- package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentSeverityService.js +67 -0
- package/build/dist/Server/Services/IncidentSeverityService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +12 -4
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/InventoryItemCustomFieldService.js +9 -0
- package/build/dist/Server/Services/InventoryItemCustomFieldService.js.map +1 -0
- package/build/dist/Server/Services/{TelemetryEntityRelationshipService.js → InventoryItemRelationshipService.js} +9 -6
- package/build/dist/Server/Services/InventoryItemRelationshipService.js.map +1 -0
- package/build/dist/Server/Services/{TelemetryEntityService.js → InventoryItemService.js} +158 -23
- package/build/dist/Server/Services/InventoryItemService.js.map +1 -0
- package/build/dist/Server/Services/LogAggregationService.js +27 -8
- package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
- package/build/dist/Server/Services/MetricAggregationService.js +80 -0
- package/build/dist/Server/Services/MetricAggregationService.js.map +1 -1
- package/build/dist/Server/Services/MetricService.js +6 -6
- package/build/dist/Server/Services/MetricService.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceLinkRuleService.js +9 -0
- package/build/dist/Server/Services/NetworkDeviceLinkRuleService.js.map +1 -0
- package/build/dist/Server/Services/NetworkDeviceLinkService.js +71 -0
- package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -0
- package/build/dist/Server/Services/NetworkDeviceService.js +113 -0
- package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
- package/build/dist/Server/Services/NetworkSiteService.js +53 -13
- package/build/dist/Server/Services/NetworkSiteService.js.map +1 -1
- package/build/dist/Server/Services/NetworkTopologySuppressionService.js +85 -0
- package/build/dist/Server/Services/NetworkTopologySuppressionService.js.map +1 -0
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +48 -32
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js +8 -0
- package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.js +51 -12
- package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +57 -13
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallNotificationAlertingService.js +548 -0
- package/build/dist/Server/Services/OnCallNotificationAlertingService.js.map +1 -0
- package/build/dist/Server/Services/OnCallReadinessService.js +1961 -0
- package/build/dist/Server/Services/OnCallReadinessService.js.map +1 -0
- package/build/dist/Server/Services/OnCallSetupReminderService.js +738 -0
- package/build/dist/Server/Services/OnCallSetupReminderService.js.map +1 -0
- package/build/dist/Server/Services/ProfileAggregationService.js +68 -4
- package/build/dist/Server/Services/ProfileAggregationService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageService.js +11 -10
- package/build/dist/Server/Services/StatusPageService.js.map +1 -1
- package/build/dist/Server/Services/TeamComplianceService.js +312 -160
- package/build/dist/Server/Services/TeamComplianceService.js.map +1 -1
- package/build/dist/Server/Services/UserCallService.js +24 -1
- package/build/dist/Server/Services/UserCallService.js.map +1 -1
- package/build/dist/Server/Services/UserEmailService.js +24 -1
- package/build/dist/Server/Services/UserEmailService.js.map +1 -1
- package/build/dist/Server/Services/UserNotificationRuleAdminService.js +858 -0
- package/build/dist/Server/Services/UserNotificationRuleAdminService.js.map +1 -0
- package/build/dist/Server/Services/UserNotificationRuleService.js +2830 -175
- package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
- package/build/dist/Server/Services/UserOnCallLogService.js +488 -43
- package/build/dist/Server/Services/UserOnCallLogService.js.map +1 -1
- package/build/dist/Server/Services/UserPushService.js +26 -0
- package/build/dist/Server/Services/UserPushService.js.map +1 -1
- package/build/dist/Server/Services/UserService.js +10 -0
- package/build/dist/Server/Services/UserService.js.map +1 -1
- package/build/dist/Server/Services/UserSmsService.js +24 -1
- package/build/dist/Server/Services/UserSmsService.js.map +1 -1
- package/build/dist/Server/Services/UserTelegramService.js +22 -1
- package/build/dist/Server/Services/UserTelegramService.js.map +1 -1
- package/build/dist/Server/Services/UserWebhookService.js +25 -1
- package/build/dist/Server/Services/UserWebhookService.js.map +1 -1
- package/build/dist/Server/Services/UserWhatsAppService.js +22 -1
- package/build/dist/Server/Services/UserWhatsAppService.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/BasePermission.js +12 -1
- package/build/dist/Server/Types/Database/Permissions/BasePermission.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/CreatePermission.js +126 -0
- package/build/dist/Server/Types/Database/Permissions/CreatePermission.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.js +254 -0
- package/build/dist/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.js.map +1 -0
- package/build/dist/Server/Types/Database/Permissions/QueryPermission.js +47 -2
- package/build/dist/Server/Types/Database/Permissions/QueryPermission.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/TenantPermission.js +7 -0
- package/build/dist/Server/Types/Database/Permissions/TenantPermission.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Delete.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Delete.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Get.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Get.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Patch.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Patch.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Post.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Post.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Put.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Put.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Utils.js +28 -0
- package/build/dist/Server/Types/Workflow/Components/API/Utils.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.js +21 -5
- package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.js +14 -10
- package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/BaseModel/ModelArguments.js +31 -0
- package/build/dist/Server/Types/Workflow/Components/BaseModel/ModelArguments.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js +3 -9
- package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/Email.js +15 -4
- package/build/dist/Server/Types/Workflow/Components/Email.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/JavaScript.js +7 -2
- package/build/dist/Server/Types/Workflow/Components/JavaScript.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.js.map +1 -1
- package/build/dist/Server/Types/Workflow/TriggerCode.js.map +1 -1
- package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js +514 -56
- package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js.map +1 -1
- package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js +20 -3
- package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js.map +1 -1
- package/build/dist/Server/Utils/AI/Chat/ObservabilityChatPrompt.js +19 -6
- package/build/dist/Server/Utils/AI/Chat/ObservabilityChatPrompt.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js +7 -0
- package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/AIActionTools.js +2 -2
- package/build/dist/Server/Utils/AI/Toolbox/AIActionTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/AIMetaTools.js +692 -0
- package/build/dist/Server/Utils/AI/Toolbox/AIMetaTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/AlertTools.js +148 -12
- package/build/dist/Server/Utils/AI/Toolbox/AlertTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js +157 -10
- package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/Index.js +37 -0
- package/build/dist/Server/Utils/AI/Toolbox/Index.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/MonitorTools.js +259 -14
- package/build/dist/Server/Utils/AI/Toolbox/MonitorTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/NoteWriteTools.js +235 -0
- package/build/dist/Server/Utils/AI/Toolbox/NoteWriteTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/OnCallTools.js +1000 -0
- package/build/dist/Server/Utils/AI/Toolbox/OnCallTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/RunbookTools.js +356 -0
- package/build/dist/Server/Utils/AI/Toolbox/RunbookTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/SloTools.js +394 -0
- package/build/dist/Server/Utils/AI/Toolbox/SloTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/StatusPageTools.js +465 -0
- package/build/dist/Server/Utils/AI/Toolbox/StatusPageTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/TeamTools.js +280 -0
- package/build/dist/Server/Utils/AI/Toolbox/TeamTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/TimelineTools.js +527 -0
- package/build/dist/Server/Utils/AI/Toolbox/TimelineTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/WorkflowProbeTools.js +548 -0
- package/build/dist/Server/Utils/AI/Toolbox/WorkflowProbeTools.js.map +1 -0
- package/build/dist/Server/Utils/ClientIp.js +137 -0
- package/build/dist/Server/Utils/ClientIp.js.map +1 -0
- package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js +38 -0
- package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js.map +1 -1
- package/build/dist/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.js +89 -0
- package/build/dist/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.js.map +1 -0
- package/build/dist/Server/Utils/Dashboard/PublicDashboardSloWidget.js +77 -0
- package/build/dist/Server/Utils/Dashboard/PublicDashboardSloWidget.js.map +1 -0
- package/build/dist/Server/Utils/Express.js +12 -12
- package/build/dist/Server/Utils/Express.js.map +1 -1
- package/build/dist/Server/Utils/LLM/LLMService.js +70 -7
- package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +121 -11
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
- package/build/dist/Server/Utils/SSRFProtection.js +82 -21
- package/build/dist/Server/Utils/SSRFProtection.js.map +1 -1
- package/build/dist/Server/Utils/StartServer.js +12 -4
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +165 -18
- package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +507 -0
- package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +122 -47
- package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
- package/build/dist/Server/Utils/VM/VMAPI.js +51 -4
- package/build/dist/Server/Utils/VM/VMAPI.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +7 -3
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
- package/build/dist/Types/AI/AIChatMessageStatus.js +8 -1
- package/build/dist/Types/AI/AIChatMessageStatus.js.map +1 -1
- package/build/dist/Types/AI/AIChatTypes.js +12 -0
- package/build/dist/Types/AI/AIChatTypes.js.map +1 -1
- package/build/dist/Types/Database/AccessControl/OwnerOnlyColumn.js +60 -0
- package/build/dist/Types/Database/AccessControl/OwnerOnlyColumn.js.map +1 -0
- package/build/dist/Types/Exception/ExceptionCode.js +2 -0
- package/build/dist/Types/Exception/ExceptionCode.js.map +1 -1
- package/build/dist/Types/Exception/ServiceUnavailableException.js +8 -0
- package/build/dist/Types/Exception/ServiceUnavailableException.js.map +1 -0
- package/build/dist/Types/Exception/TooManyRequestsException.js +8 -0
- package/build/dist/Types/Exception/TooManyRequestsException.js.map +1 -0
- package/build/dist/Types/IP/IP.js +87 -43
- package/build/dist/Types/IP/IP.js.map +1 -1
- package/build/dist/Types/NetworkDevice/NetworkDeviceMonitoringMethod.js +50 -0
- package/build/dist/Types/NetworkDevice/NetworkDeviceMonitoringMethod.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/Layer.js +186 -123
- package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.js +13 -0
- package/build/dist/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +105 -11
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
- package/build/dist/Types/Permission.js +174 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/SerializableObjectDictionary.js +133 -39
- package/build/dist/Types/SerializableObjectDictionary.js.map +1 -1
- package/build/dist/Types/Telemetry/EntityRelationshipType.js +1 -1
- package/build/dist/Types/Telemetry/EntitySource.js +39 -0
- package/build/dist/Types/Telemetry/EntitySource.js.map +1 -0
- package/build/dist/Types/Telemetry/EntityType.js +28 -1
- package/build/dist/Types/Telemetry/EntityType.js.map +1 -1
- package/build/dist/Types/Telemetry/EntityTypeGroups.js +57 -0
- package/build/dist/Types/Telemetry/EntityTypeGroups.js.map +1 -0
- package/build/dist/Types/Workflow/Component.js +21 -0
- package/build/dist/Types/Workflow/Component.js.map +1 -1
- package/build/dist/Types/Workflow/Components/API.js +35 -0
- package/build/dist/Types/Workflow/Components/API.js.map +1 -1
- package/build/dist/Types/Workflow/Components/BaseModel.js +68 -26
- package/build/dist/Types/Workflow/Components/BaseModel.js.map +1 -1
- package/build/dist/Types/Workflow/Components/Discord.js +1 -0
- package/build/dist/Types/Workflow/Components/Discord.js.map +1 -1
- package/build/dist/Types/Workflow/Components/Email.js +12 -3
- package/build/dist/Types/Workflow/Components/Email.js.map +1 -1
- package/build/dist/Types/Workflow/Components/JavaScript.js +7 -0
- package/build/dist/Types/Workflow/Components/JavaScript.js.map +1 -1
- package/build/dist/Types/Workflow/Components/MicrosoftTeams.js +3 -2
- package/build/dist/Types/Workflow/Components/MicrosoftTeams.js.map +1 -1
- package/build/dist/Types/Workflow/Components/Slack.js +1 -0
- package/build/dist/Types/Workflow/Components/Slack.js.map +1 -1
- package/build/dist/Types/Workflow/Components/Telegram.js +1 -0
- package/build/dist/Types/Workflow/Components/Telegram.js.map +1 -1
- package/build/dist/Types/Workflow/StepTrace.js +104 -0
- package/build/dist/Types/Workflow/StepTrace.js.map +1 -0
- package/build/dist/Types/Workflow/TemplateSyntax.js +338 -0
- package/build/dist/Types/Workflow/TemplateSyntax.js.map +1 -0
- package/build/dist/Types/Workflow/Templates.js +2111 -0
- package/build/dist/Types/Workflow/Templates.js.map +1 -0
- package/build/dist/UI/Components/Calendar/Calendar.js +1 -1
- package/build/dist/UI/Components/Calendar/Calendar.js.map +1 -1
- package/build/dist/UI/Components/Checkbox/Checkbox.js +1 -1
- package/build/dist/UI/Components/Checkbox/Checkbox.js.map +1 -1
- package/build/dist/UI/Components/Date/CustomTimeRangeModal.js +126 -0
- package/build/dist/UI/Components/Date/CustomTimeRangeModal.js.map +1 -0
- package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js +123 -0
- package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js.map +1 -0
- package/build/dist/UI/Components/Dictionary/Dictionary.js +47 -17
- package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
- package/build/dist/UI/Components/FormModal/BasicFormModal.js.map +1 -1
- package/build/dist/UI/Components/Forms/Fields/ColorPicker.js +52 -13
- package/build/dist/UI/Components/Forms/Fields/ColorPicker.js.map +1 -1
- package/build/dist/UI/Components/Forms/Fields/IconPicker.js +28 -12
- package/build/dist/UI/Components/Forms/Fields/IconPicker.js.map +1 -1
- package/build/dist/UI/Components/Forms/Validation.js +44 -0
- package/build/dist/UI/Components/Forms/Validation.js.map +1 -1
- package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js +27 -4
- package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js.map +1 -1
- package/build/dist/UI/Components/Input/Input.js +13 -4
- package/build/dist/UI/Components/Input/Input.js.map +1 -1
- package/build/dist/UI/Components/KeyboardShortcut/KeyboardKey.js +163 -0
- package/build/dist/UI/Components/KeyboardShortcut/KeyboardKey.js.map +1 -0
- package/build/dist/UI/Components/KeyboardShortcut/KeyboardShortcut.js +47 -0
- package/build/dist/UI/Components/KeyboardShortcut/KeyboardShortcut.js.map +1 -0
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js +4 -4
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/ActiveFilterChips.js +11 -1
- package/build/dist/UI/Components/LogsViewer/components/ActiveFilterChips.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.js +10 -8
- package/build/dist/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js +227 -14
- package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js +5 -108
- package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +5 -0
- package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsTable.js +69 -5
- package/build/dist/UI/Components/LogsViewer/components/LogsTable.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +8 -0
- package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/types.js.map +1 -1
- package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js +9 -2
- package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js.map +1 -1
- package/build/dist/UI/Components/Modal/Modal.js +31 -5
- package/build/dist/UI/Components/Modal/Modal.js.map +1 -1
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +8 -21
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
- package/build/dist/UI/Components/ProjectInvitations/PendingProjectInvitations.js +251 -0
- package/build/dist/UI/Components/ProjectInvitations/PendingProjectInvitations.js.map +1 -0
- package/build/dist/UI/Components/SimpleLogViewer/SimpleLogViewer.js +9 -2
- package/build/dist/UI/Components/SimpleLogViewer/SimpleLogViewer.js.map +1 -1
- package/build/dist/UI/Components/Table/Table.js +27 -15
- package/build/dist/UI/Components/Table/Table.js.map +1 -1
- package/build/dist/UI/Components/Table/TableBody.js +24 -18
- package/build/dist/UI/Components/Table/TableBody.js.map +1 -1
- package/build/dist/UI/Components/Table/TableHeader.js +9 -1
- package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
- package/build/dist/UI/Components/Table/TableRow.js +29 -21
- package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js +5 -105
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js +261 -14
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
- package/build/dist/UI/Components/Workflow/Component.js +20 -19
- package/build/dist/UI/Components/Workflow/Component.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ComponentReturnValueViewer.js +10 -1
- package/build/dist/UI/Components/Workflow/ComponentReturnValueViewer.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js +35 -7
- package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ComponentValuePickerModal.js +57 -7
- package/build/dist/UI/Components/Workflow/ComponentValuePickerModal.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ComponentsModal.js +53 -18
- package/build/dist/UI/Components/Workflow/ComponentsModal.js.map +1 -1
- package/build/dist/UI/Components/Workflow/DocumentationViewer.js +19 -6
- package/build/dist/UI/Components/Workflow/DocumentationViewer.js.map +1 -1
- package/build/dist/UI/Components/Workflow/GraphLint.js +425 -0
- package/build/dist/UI/Components/Workflow/GraphLint.js.map +1 -0
- package/build/dist/UI/Components/Workflow/GraphLintSummary.js +231 -0
- package/build/dist/UI/Components/Workflow/GraphLintSummary.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ModelColumnEditor.js +320 -0
- package/build/dist/UI/Components/Workflow/ModelColumnEditor.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ModelSchema.js +156 -0
- package/build/dist/UI/Components/Workflow/ModelSchema.js.map +1 -0
- package/build/dist/UI/Components/Workflow/RunForm.js +22 -6
- package/build/dist/UI/Components/Workflow/RunForm.js.map +1 -1
- package/build/dist/UI/Components/Workflow/RunStatusWatcher.js +76 -0
- package/build/dist/UI/Components/Workflow/RunStatusWatcher.js.map +1 -0
- package/build/dist/UI/Components/Workflow/StepTraceViewer.js +76 -0
- package/build/dist/UI/Components/Workflow/StepTraceViewer.js.map +1 -0
- package/build/dist/UI/Components/Workflow/UseRunWatch.js +123 -0
- package/build/dist/UI/Components/Workflow/UseRunWatch.js.map +1 -0
- package/build/dist/UI/Components/Workflow/Utils.js +73 -1
- package/build/dist/UI/Components/Workflow/Utils.js.map +1 -1
- package/build/dist/UI/Components/Workflow/VariableModal.js +3 -2
- package/build/dist/UI/Components/Workflow/VariableModal.js.map +1 -1
- package/build/dist/UI/Components/Workflow/Workflow.js +92 -7
- package/build/dist/UI/Components/Workflow/Workflow.js.map +1 -1
- package/build/dist/UI/Components/Workflow/WorkflowIssuesModal.js +99 -0
- package/build/dist/UI/Components/Workflow/WorkflowIssuesModal.js.map +1 -0
- package/build/dist/UI/Components/Workflow/WorkflowLogModal.js +56 -0
- package/build/dist/UI/Components/Workflow/WorkflowLogModal.js.map +1 -0
- package/build/dist/UI/Components/Workflow/WorkflowStatusBar.js +92 -0
- package/build/dist/UI/Components/Workflow/WorkflowStatusBar.js.map +1 -0
- package/build/dist/UI/Types/LayeredDismissal.js +21 -0
- package/build/dist/UI/Types/LayeredDismissal.js.map +1 -0
- package/build/dist/UI/Types/UseAnchoredFieldPopup.js +74 -1
- package/build/dist/UI/Types/UseAnchoredFieldPopup.js.map +1 -1
- package/build/dist/UI/Utils/AIChatExport/ConversationMarkdown.js +9 -0
- package/build/dist/UI/Utils/AIChatExport/ConversationMarkdown.js.map +1 -1
- package/build/dist/UI/Utils/ModelAPI/ModelAPI.js +1 -1
- package/build/dist/UI/Utils/ModelAPI/ModelAPI.js.map +1 -1
- package/build/dist/UI/Utils/Platform.js +118 -0
- package/build/dist/UI/Utils/Platform.js.map +1 -0
- package/build/dist/UI/Utils/ProjectInvitationDisplay.js +106 -0
- package/build/dist/UI/Utils/ProjectInvitationDisplay.js.map +1 -0
- package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js +108 -0
- package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js.map +1 -0
- package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +386 -0
- package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -0
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +661 -126
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
- package/build/dist/Utils/Telemetry/CrossSignalScope.js +328 -0
- package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -0
- package/build/dist/Utils/Telemetry/EntityKey.js +57 -5
- package/build/dist/Utils/Telemetry/EntityKey.js.map +1 -1
- package/build/dist/Utils/Telemetry/EntityRelationship.js +1 -1
- package/jest.config.json +1 -0
- package/package.json +1 -1
- package/build/dist/Models/DatabaseModels/TelemetryEntity.js.map +0 -1
- package/build/dist/Models/DatabaseModels/TelemetryEntityRelationship.js.map +0 -1
- package/build/dist/Server/Services/TelemetryEntityRelationshipService.js.map +0 -1
- package/build/dist/Server/Services/TelemetryEntityService.js.map +0 -1
|
@@ -0,0 +1,3606 @@
|
|
|
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
|
+
cleanup,
|
|
12
|
+
fireEvent,
|
|
13
|
+
render,
|
|
14
|
+
screen,
|
|
15
|
+
waitFor,
|
|
16
|
+
within,
|
|
17
|
+
} from "@testing-library/react";
|
|
18
|
+
import React from "react";
|
|
19
|
+
import getJestMockFunction, { MockFunction } from "../../MockType";
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* The three readiness surfaces, rendered for real.
|
|
23
|
+
*
|
|
24
|
+
* The thing being replaced here - Teams > Compliance - printed a binary
|
|
25
|
+
* Compliant / Non-Compliant verdict as prose, hid itself entirely when a team
|
|
26
|
+
* had not opted in, and offered no next step. Every assertion in this file aims
|
|
27
|
+
* at one of the properties that makes the replacement worth having, and each of
|
|
28
|
+
* those properties is invisible to a unit test of the pure helpers:
|
|
29
|
+
*
|
|
30
|
+
* - the three states have to LOOK different on screen, not merely carry
|
|
31
|
+
* different enum members, because the whole point is that an admin can
|
|
32
|
+
* triage without reading;
|
|
33
|
+
* - the chip dot is drawn only for amber and red, because a dot on every
|
|
34
|
+
* healthy chip is a dot nobody reads;
|
|
35
|
+
* - a slow request has to keep the card's shape (skeletons), because a card
|
|
36
|
+
* that blanks reads as broken rather than loading;
|
|
37
|
+
* - a warning ships with its fix, or it is just nagging;
|
|
38
|
+
* - and no unmasked email address or phone number ever reaches the DOM.
|
|
39
|
+
*
|
|
40
|
+
* That last one is a second barrier, on purpose. The server masks; this asserts
|
|
41
|
+
* the browser layer never un-masks, so a future change that starts rendering a
|
|
42
|
+
* raw field the API happens to include fails here even if the server-side
|
|
43
|
+
* masking tests stay green.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const getMock: MockFunction = getJestMockFunction();
|
|
47
|
+
const postMock: MockFunction = getJestMockFunction();
|
|
48
|
+
const getCommonHeadersMock: MockFunction = getJestMockFunction();
|
|
49
|
+
|
|
50
|
+
/*
|
|
51
|
+
* The arrow wrappers are load bearing: jest.mock is hoisted above the compiled
|
|
52
|
+
* requires, so the consts above are still in their temporal dead zone when the
|
|
53
|
+
* factory body runs. Dereferencing them lazily, at call time, is what works.
|
|
54
|
+
*/
|
|
55
|
+
jest.mock("../../../UI/Utils/API/API", () => {
|
|
56
|
+
return {
|
|
57
|
+
__esModule: true,
|
|
58
|
+
default: {
|
|
59
|
+
get: (...args: Array<any>) => {
|
|
60
|
+
return getMock(...args);
|
|
61
|
+
},
|
|
62
|
+
/*
|
|
63
|
+
* The setup-reminder endpoint. It is the only WRITE either surface makes,
|
|
64
|
+
* and it is stubbed rather than exercised because what these tests are
|
|
65
|
+
* about is whether the page tells the truth about the ANSWER - which
|
|
66
|
+
* outcomes it renders, and which it must never render as a success.
|
|
67
|
+
*/
|
|
68
|
+
post: (...args: Array<any>) => {
|
|
69
|
+
return postMock(...args);
|
|
70
|
+
},
|
|
71
|
+
/*
|
|
72
|
+
* The real helper unwraps an HTTPErrorResponse into its server message and
|
|
73
|
+
* falls back to a generic line for anything else. Both branches matter
|
|
74
|
+
* here: the readiness fetch fails with an HTTPErrorResponse, while a
|
|
75
|
+
* rejected reminder send carries a plain Error whose text is the entire
|
|
76
|
+
* point of the assertion.
|
|
77
|
+
*/
|
|
78
|
+
getFriendlyMessage: (error: unknown) => {
|
|
79
|
+
return error instanceof Error
|
|
80
|
+
? error.message
|
|
81
|
+
: "Could not load readiness";
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
|
|
88
|
+
return {
|
|
89
|
+
__esModule: true,
|
|
90
|
+
default: {
|
|
91
|
+
getCommonHeaders: (...args: Array<any>) => {
|
|
92
|
+
return getCommonHeadersMock(...args);
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
jest.mock("react-i18next", () => {
|
|
99
|
+
return {
|
|
100
|
+
useTranslation: () => {
|
|
101
|
+
return {
|
|
102
|
+
t: (key: string, options?: { defaultValue?: string }): string => {
|
|
103
|
+
return options?.defaultValue ?? key;
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
import ResponderReadinessCard from "../../../../App/FeatureSet/Dashboard/src/Components/OnCallPolicy/Readiness/ResponderReadinessCard";
|
|
111
|
+
import ReadinessDot from "../../../../App/FeatureSet/Dashboard/src/Components/OnCallPolicy/Readiness/ReadinessDot";
|
|
112
|
+
import EscalationSummary, {
|
|
113
|
+
EscalationLevelSummary,
|
|
114
|
+
EscalationResponder,
|
|
115
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/OnCallPolicy/EscalationRule/EscalationSummary";
|
|
116
|
+
import {
|
|
117
|
+
UserReadinessWire,
|
|
118
|
+
parseReadinessSummary,
|
|
119
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/OnCallPolicy/Readiness/ReadinessTypes";
|
|
120
|
+
import OnCallReadinessPage from "../../../../App/FeatureSet/Dashboard/src/Pages/OnCallDuty/Readiness";
|
|
121
|
+
import HTTPErrorResponse from "../../../Types/API/HTTPErrorResponse";
|
|
122
|
+
import HTTPResponse from "../../../Types/API/HTTPResponse";
|
|
123
|
+
import Route from "../../../Types/API/Route";
|
|
124
|
+
import { JSONArray, JSONObject } from "../../../Types/JSON";
|
|
125
|
+
import NotificationRuleType from "../../../Types/NotificationRule/NotificationRuleType";
|
|
126
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
127
|
+
|
|
128
|
+
const POLICY_ID: ObjectID = new ObjectID(
|
|
129
|
+
"99999999-9999-4999-8999-999999999999",
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const SEV1_ID: string = "aaaaaaaa-1111-4111-8111-111111111111";
|
|
133
|
+
const SEV2_ID: string = "bbbbbbbb-2222-4222-8222-222222222222";
|
|
134
|
+
|
|
135
|
+
/*
|
|
136
|
+
* Incident severities and alert severities are two different tables with two
|
|
137
|
+
* disjoint sets of ids, and a fixture that reuses one id for both (as the two
|
|
138
|
+
* above deliberately do, to prove the shared-name case still works) cannot catch
|
|
139
|
+
* the defect the matrix was rebuilt for: rows keyed on severity id alone put
|
|
140
|
+
* every severity under all four rule-type columns, so an alert severity got an
|
|
141
|
+
* "Incident" column that can never hold anything. These two ids appear under
|
|
142
|
+
* exactly one kind each, which is the only shape in which an impossible cell is
|
|
143
|
+
* visible.
|
|
144
|
+
*/
|
|
145
|
+
const INCIDENT_ONLY_SEV_ID: string = "dddddddd-4444-4444-8444-444444444444";
|
|
146
|
+
const ALERT_ONLY_SEV_ID: string = "eeeeeeee-5555-4555-8555-555555555555";
|
|
147
|
+
|
|
148
|
+
const INCIDENT_ONLY_SEV_NAME: string = "Sev1 Incident";
|
|
149
|
+
const ALERT_ONLY_SEV_NAME: string = "Sev1 Alert";
|
|
150
|
+
|
|
151
|
+
const SIGNED_IN_USER_ID: string = "cccccccc-3333-4333-8333-333333333333";
|
|
152
|
+
|
|
153
|
+
/*
|
|
154
|
+
* The raw values that must never survive the trip to the DOM. They are planted
|
|
155
|
+
* INSIDE the fixture payloads, on the same objects that carry the masked ones,
|
|
156
|
+
* so this is not a tautology: an API that leaks an extra field, or a component
|
|
157
|
+
* that reads one, shows up as one of these strings in the rendered markup.
|
|
158
|
+
*/
|
|
159
|
+
const RAW_EMAIL: string = "jane.doe@example.com";
|
|
160
|
+
const RAW_PHONE: string = "+15551234821";
|
|
161
|
+
const RAW_TELEGRAM: string = "@janedoe_oncall";
|
|
162
|
+
const RAW_WEBHOOK_URL: string = "https://hooks.example.com/T0P-53CR3T-T0K3N";
|
|
163
|
+
|
|
164
|
+
// Exactly the shapes OnCallReadinessService.maskIdentifier emits.
|
|
165
|
+
const MASKED_EMAIL: string = "j•••@example.com";
|
|
166
|
+
const MASKED_PHONE: string = "+1 ••• ••• 4821";
|
|
167
|
+
const MASKED_TELEGRAM: string = "@ja•••";
|
|
168
|
+
|
|
169
|
+
const ALL_RAW_IDENTIFIERS: Array<string> = [
|
|
170
|
+
RAW_EMAIL,
|
|
171
|
+
RAW_PHONE,
|
|
172
|
+
RAW_TELEGRAM,
|
|
173
|
+
RAW_WEBHOOK_URL,
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
/*
|
|
177
|
+
* Anything shaped like a whole address or a whole phone number. The masked
|
|
178
|
+
* forms deliberately match neither: a bullet is not a local-part character, and
|
|
179
|
+
* four trailing digits are too few to be a number. Un-mask any one identifier in
|
|
180
|
+
* the fixtures and both patterns start hitting.
|
|
181
|
+
*/
|
|
182
|
+
const UNMASKED_EMAIL_PATTERN: RegExp =
|
|
183
|
+
/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/;
|
|
184
|
+
const UNMASKED_PHONE_PATTERN: RegExp = /\+?\d[\d\s().-]{6,}\d/;
|
|
185
|
+
|
|
186
|
+
interface CoverageSpec {
|
|
187
|
+
ruleType: NotificationRuleType;
|
|
188
|
+
severityId?: string | undefined;
|
|
189
|
+
severityName?: string | undefined;
|
|
190
|
+
hasRule: boolean;
|
|
191
|
+
isOptOut?: boolean | undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
type CoverageJsonFunction = (spec: CoverageSpec) => JSONObject;
|
|
195
|
+
|
|
196
|
+
/*
|
|
197
|
+
* isOptOut defaults to null rather than false on purpose: the column is
|
|
198
|
+
* nullable and every row written before it existed carries NULL, so that is the
|
|
199
|
+
* shape the UI actually receives most of the time.
|
|
200
|
+
*/
|
|
201
|
+
const coverageJson: CoverageJsonFunction = (spec: CoverageSpec): JSONObject => {
|
|
202
|
+
const cell: JSONObject = {
|
|
203
|
+
ruleType: spec.ruleType,
|
|
204
|
+
hasRule: spec.hasRule,
|
|
205
|
+
isOptOut: spec.isOptOut ?? null,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
if (spec.severityId) {
|
|
209
|
+
cell["severityId"] = spec.severityId;
|
|
210
|
+
cell["severityName"] = spec.severityName ?? "";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return cell;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
interface MethodSpec {
|
|
217
|
+
methodType: string;
|
|
218
|
+
maskedIdentifier: string;
|
|
219
|
+
isVerified: boolean;
|
|
220
|
+
// The value a leaky server - or a leaky future refactor - would hand over.
|
|
221
|
+
leakedRawValue: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
type MethodJsonFunction = (spec: MethodSpec) => JSONObject;
|
|
225
|
+
|
|
226
|
+
const methodJson: MethodJsonFunction = (spec: MethodSpec): JSONObject => {
|
|
227
|
+
return {
|
|
228
|
+
methodType: spec.methodType,
|
|
229
|
+
maskedIdentifier: spec.maskedIdentifier,
|
|
230
|
+
isVerified: spec.isVerified,
|
|
231
|
+
/*
|
|
232
|
+
* Three plausible spellings of the same mistake, planted at once. None is
|
|
233
|
+
* part of the frozen contract, so parseMethod drops all three and nothing
|
|
234
|
+
* downstream has an unmasked value available to render by accident.
|
|
235
|
+
*/
|
|
236
|
+
identifier: spec.leakedRawValue,
|
|
237
|
+
rawIdentifier: spec.leakedRawValue,
|
|
238
|
+
webhookUrl: RAW_WEBHOOK_URL,
|
|
239
|
+
};
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const VERIFIED_EMAIL_METHOD: JSONObject = methodJson({
|
|
243
|
+
methodType: "Email",
|
|
244
|
+
maskedIdentifier: MASKED_EMAIL,
|
|
245
|
+
isVerified: true,
|
|
246
|
+
leakedRawValue: RAW_EMAIL,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const VERIFIED_SMS_METHOD: JSONObject = methodJson({
|
|
250
|
+
methodType: "SMS",
|
|
251
|
+
maskedIdentifier: MASKED_PHONE,
|
|
252
|
+
isVerified: true,
|
|
253
|
+
leakedRawValue: RAW_PHONE,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const UNVERIFIED_TELEGRAM_METHOD: JSONObject = methodJson({
|
|
257
|
+
methodType: "Telegram",
|
|
258
|
+
maskedIdentifier: MASKED_TELEGRAM,
|
|
259
|
+
isVerified: false,
|
|
260
|
+
leakedRawValue: RAW_TELEGRAM,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const SEVERITY_RULE_TYPES: Array<NotificationRuleType> = [
|
|
264
|
+
NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
265
|
+
NotificationRuleType.ON_CALL_EXECUTED_ALERT,
|
|
266
|
+
NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE,
|
|
267
|
+
NotificationRuleType.ON_CALL_EXECUTED_ALERT_EPISODE,
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
type FullCoverageFunction = (hasRule: boolean) => JSONArray;
|
|
271
|
+
|
|
272
|
+
const fullCoverage: FullCoverageFunction = (hasRule: boolean): JSONArray => {
|
|
273
|
+
const cells: JSONArray = [];
|
|
274
|
+
|
|
275
|
+
for (const severity of [
|
|
276
|
+
{ id: SEV1_ID, name: "Sev1" },
|
|
277
|
+
{ id: SEV2_ID, name: "Sev2" },
|
|
278
|
+
]) {
|
|
279
|
+
for (const ruleType of SEVERITY_RULE_TYPES) {
|
|
280
|
+
cells.push(
|
|
281
|
+
coverageJson({
|
|
282
|
+
ruleType: ruleType,
|
|
283
|
+
severityId: severity.id,
|
|
284
|
+
severityName: severity.name,
|
|
285
|
+
hasRule: hasRule,
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
cells.push(
|
|
292
|
+
coverageJson({
|
|
293
|
+
ruleType: NotificationRuleType.WHEN_USER_GOES_ON_CALL,
|
|
294
|
+
hasRule: hasRule,
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
cells.push(
|
|
298
|
+
coverageJson({
|
|
299
|
+
ruleType: NotificationRuleType.WHEN_USER_GOES_OFF_CALL,
|
|
300
|
+
hasRule: hasRule,
|
|
301
|
+
}),
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
return cells;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/*
|
|
308
|
+
* One responder carrying all four cell states at once, so a single expanded
|
|
309
|
+
* matrix proves the four are drawn differently rather than four fixtures each
|
|
310
|
+
* proving one. Incident/Sev2 is muted; Incident-episode/Sev2 is absent from the
|
|
311
|
+
* payload entirely, which is the "not reported" state and is deliberately NOT
|
|
312
|
+
* the same claim as "no rule".
|
|
313
|
+
*/
|
|
314
|
+
const PARTIAL_COVERAGE: JSONArray = [
|
|
315
|
+
coverageJson({
|
|
316
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
317
|
+
severityId: SEV1_ID,
|
|
318
|
+
severityName: "Sev1",
|
|
319
|
+
hasRule: true,
|
|
320
|
+
}),
|
|
321
|
+
coverageJson({
|
|
322
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_ALERT,
|
|
323
|
+
severityId: SEV1_ID,
|
|
324
|
+
severityName: "Sev1",
|
|
325
|
+
hasRule: false,
|
|
326
|
+
}),
|
|
327
|
+
coverageJson({
|
|
328
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE,
|
|
329
|
+
severityId: SEV1_ID,
|
|
330
|
+
severityName: "Sev1",
|
|
331
|
+
hasRule: true,
|
|
332
|
+
}),
|
|
333
|
+
coverageJson({
|
|
334
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_ALERT_EPISODE,
|
|
335
|
+
severityId: SEV1_ID,
|
|
336
|
+
severityName: "Sev1",
|
|
337
|
+
hasRule: false,
|
|
338
|
+
}),
|
|
339
|
+
coverageJson({
|
|
340
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
341
|
+
severityId: SEV2_ID,
|
|
342
|
+
severityName: "Sev2",
|
|
343
|
+
hasRule: false,
|
|
344
|
+
isOptOut: true,
|
|
345
|
+
}),
|
|
346
|
+
coverageJson({
|
|
347
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_ALERT,
|
|
348
|
+
severityId: SEV2_ID,
|
|
349
|
+
severityName: "Sev2",
|
|
350
|
+
hasRule: true,
|
|
351
|
+
}),
|
|
352
|
+
coverageJson({
|
|
353
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_ALERT_EPISODE,
|
|
354
|
+
severityId: SEV2_ID,
|
|
355
|
+
severityName: "Sev2",
|
|
356
|
+
hasRule: true,
|
|
357
|
+
}),
|
|
358
|
+
coverageJson({
|
|
359
|
+
ruleType: NotificationRuleType.WHEN_USER_GOES_ON_CALL,
|
|
360
|
+
hasRule: true,
|
|
361
|
+
}),
|
|
362
|
+
coverageJson({
|
|
363
|
+
ruleType: NotificationRuleType.WHEN_USER_GOES_OFF_CALL,
|
|
364
|
+
hasRule: false,
|
|
365
|
+
}),
|
|
366
|
+
];
|
|
367
|
+
|
|
368
|
+
// Alert/Sev1, Alert-episode/Sev1 and off-call: no rule and no opt-out.
|
|
369
|
+
const PARTIAL_GAP_COUNT: number = 3;
|
|
370
|
+
|
|
371
|
+
const PARTIAL_GAP_LABEL: string = `${PARTIAL_GAP_COUNT} gaps`;
|
|
372
|
+
|
|
373
|
+
const READY_USER_ID: string = "11111111-1111-4111-8111-111111111111";
|
|
374
|
+
const PARTIAL_USER_ID: string = "22222222-2222-4222-8222-222222222222";
|
|
375
|
+
const UNREACHABLE_USER_ID: string = "33333333-3333-4333-8333-333333333333";
|
|
376
|
+
const CHANNELS_OFF_USER_ID: string = "55555555-5555-4555-8555-555555555555";
|
|
377
|
+
const TWO_TEAM_USER_ID: string = "77777777-7777-4777-8777-777777777777";
|
|
378
|
+
|
|
379
|
+
/*
|
|
380
|
+
* Teams, as the readiness contract carries them: the on-call teams that page a
|
|
381
|
+
* responder, not every team they happen to belong to. Only a responder whose
|
|
382
|
+
* `reachedVia` includes Team may carry any, which is what makes the "Teams"
|
|
383
|
+
* column and the "Reached via" column two views of one fact rather than two
|
|
384
|
+
* facts that can disagree.
|
|
385
|
+
*/
|
|
386
|
+
const PLATFORM_TEAM_ID: string = "a1a1a1a1-1111-4111-8111-aaaaaaaaaaaa";
|
|
387
|
+
const PAYMENTS_TEAM_ID: string = "b2b2b2b2-2222-4222-8222-bbbbbbbbbbbb";
|
|
388
|
+
|
|
389
|
+
const PLATFORM_TEAM: JSONObject = { _id: PLATFORM_TEAM_ID, name: "Platform" };
|
|
390
|
+
const PAYMENTS_TEAM: JSONObject = { _id: PAYMENTS_TEAM_ID, name: "Payments" };
|
|
391
|
+
|
|
392
|
+
const READY_USER: JSONObject = {
|
|
393
|
+
userId: READY_USER_ID,
|
|
394
|
+
userName: "Ada Ready",
|
|
395
|
+
userEmail: "ada.ready@example.com",
|
|
396
|
+
status: "Ready",
|
|
397
|
+
methods: [VERIFIED_EMAIL_METHOD],
|
|
398
|
+
coverage: fullCoverage(true),
|
|
399
|
+
reasons: [],
|
|
400
|
+
reachedVia: ["Team"],
|
|
401
|
+
teams: [PLATFORM_TEAM],
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const PARTIAL_USER: JSONObject = {
|
|
405
|
+
userId: PARTIAL_USER_ID,
|
|
406
|
+
userName: "Jane Partial",
|
|
407
|
+
userEmail: "jane.partial@example.com",
|
|
408
|
+
status: "PartiallyReady",
|
|
409
|
+
methods: [
|
|
410
|
+
VERIFIED_EMAIL_METHOD,
|
|
411
|
+
VERIFIED_SMS_METHOD,
|
|
412
|
+
UNVERIFIED_TELEGRAM_METHOD,
|
|
413
|
+
],
|
|
414
|
+
coverage: PARTIAL_COVERAGE,
|
|
415
|
+
reasons: ["No notification rule for alerts at Sev1."],
|
|
416
|
+
reachedVia: ["Schedule", "Override"],
|
|
417
|
+
teams: [],
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const UNREACHABLE_USER: JSONObject = {
|
|
421
|
+
userId: UNREACHABLE_USER_ID,
|
|
422
|
+
userName: "Zed Unreachable",
|
|
423
|
+
userEmail: "zed.unreachable@example.com",
|
|
424
|
+
status: "NotReachable",
|
|
425
|
+
methods: [],
|
|
426
|
+
coverage: fullCoverage(false),
|
|
427
|
+
reasons: ["No verified notification method on this account."],
|
|
428
|
+
reachedVia: ["Direct"],
|
|
429
|
+
teams: [],
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
/*
|
|
433
|
+
* A responder two attached teams page. The interesting case for the team filter:
|
|
434
|
+
* they have to appear under EITHER team, because removing them from one does not
|
|
435
|
+
* stop the other paging them - which is the same reasoning `reachedVia` carries
|
|
436
|
+
* every source rather than the first one found.
|
|
437
|
+
*/
|
|
438
|
+
const TWO_TEAM_USER: JSONObject = {
|
|
439
|
+
userId: TWO_TEAM_USER_ID,
|
|
440
|
+
userName: "Ravi Twoteams",
|
|
441
|
+
userEmail: "ravi.twoteams@example.com",
|
|
442
|
+
status: "PartiallyReady",
|
|
443
|
+
methods: [VERIFIED_EMAIL_METHOD],
|
|
444
|
+
coverage: PARTIAL_COVERAGE,
|
|
445
|
+
reasons: ["No notification rule for alerts at Sev1."],
|
|
446
|
+
reachedVia: ["Team"],
|
|
447
|
+
teams: [PLATFORM_TEAM, PAYMENTS_TEAM],
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
/*
|
|
451
|
+
* The second, quieter way to be unreachable: verified methods on file, every one
|
|
452
|
+
* of them on a channel the project has switched off. It is NotReachable like Zed
|
|
453
|
+
* is, but the fix is a project setting rather than an email asking somebody to
|
|
454
|
+
* verify a phone they already verified — so the copy has to tell them apart.
|
|
455
|
+
*/
|
|
456
|
+
const CHANNELS_OFF_USER: JSONObject = {
|
|
457
|
+
userId: CHANNELS_OFF_USER_ID,
|
|
458
|
+
userName: "Sam Switchedoff",
|
|
459
|
+
userEmail: "sam.switchedoff@example.com",
|
|
460
|
+
status: "NotReachable",
|
|
461
|
+
methods: [VERIFIED_SMS_METHOD],
|
|
462
|
+
coverage: fullCoverage(true),
|
|
463
|
+
reasons: [
|
|
464
|
+
"Their only notification methods are on channels this project has switched off",
|
|
465
|
+
],
|
|
466
|
+
reachedVia: ["Direct"],
|
|
467
|
+
teams: [],
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
/*
|
|
471
|
+
* A responder whose incident severities and alert severities are different rows
|
|
472
|
+
* in different tables, which is what every real project looks like. Each of the
|
|
473
|
+
* four cells below is a pair that genuinely exists; anything else the matrix
|
|
474
|
+
* draws for this responder is a cell that cannot exist.
|
|
475
|
+
*/
|
|
476
|
+
const DISTINCT_KIND_COVERAGE: JSONArray = [
|
|
477
|
+
coverageJson({
|
|
478
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
479
|
+
severityId: INCIDENT_ONLY_SEV_ID,
|
|
480
|
+
severityName: INCIDENT_ONLY_SEV_NAME,
|
|
481
|
+
hasRule: true,
|
|
482
|
+
}),
|
|
483
|
+
coverageJson({
|
|
484
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE,
|
|
485
|
+
severityId: INCIDENT_ONLY_SEV_ID,
|
|
486
|
+
severityName: INCIDENT_ONLY_SEV_NAME,
|
|
487
|
+
hasRule: false,
|
|
488
|
+
}),
|
|
489
|
+
coverageJson({
|
|
490
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_ALERT,
|
|
491
|
+
severityId: ALERT_ONLY_SEV_ID,
|
|
492
|
+
severityName: ALERT_ONLY_SEV_NAME,
|
|
493
|
+
hasRule: true,
|
|
494
|
+
}),
|
|
495
|
+
coverageJson({
|
|
496
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_ALERT_EPISODE,
|
|
497
|
+
severityId: ALERT_ONLY_SEV_ID,
|
|
498
|
+
severityName: ALERT_ONLY_SEV_NAME,
|
|
499
|
+
hasRule: false,
|
|
500
|
+
}),
|
|
501
|
+
];
|
|
502
|
+
|
|
503
|
+
const DISTINCT_KIND_USER_ID: string = "66666666-6666-4666-8666-666666666666";
|
|
504
|
+
|
|
505
|
+
const DISTINCT_KIND_USER: JSONObject = {
|
|
506
|
+
userId: DISTINCT_KIND_USER_ID,
|
|
507
|
+
userName: "Kai Distinct",
|
|
508
|
+
userEmail: "kai.distinct@example.com",
|
|
509
|
+
status: "PartiallyReady",
|
|
510
|
+
methods: [VERIFIED_EMAIL_METHOD],
|
|
511
|
+
coverage: DISTINCT_KIND_COVERAGE,
|
|
512
|
+
reasons: [],
|
|
513
|
+
reachedVia: ["Direct"],
|
|
514
|
+
teams: [],
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
type SummaryJsonFunction = (
|
|
518
|
+
users: JSONArray,
|
|
519
|
+
isFallbackEnabled?: boolean | undefined,
|
|
520
|
+
) => JSONObject;
|
|
521
|
+
|
|
522
|
+
/*
|
|
523
|
+
* `isFallbackEnabled` defaults to true because that is the product default
|
|
524
|
+
* (disableOnCallNotificationFallback is false unless an admin sets it), and every
|
|
525
|
+
* test that does not name it is asserting behaviour in the ordinary project. The
|
|
526
|
+
* tests that pass false are asserting the state the copy used to get wrong.
|
|
527
|
+
*/
|
|
528
|
+
const summaryJson: SummaryJsonFunction = (
|
|
529
|
+
users: JSONArray,
|
|
530
|
+
isFallbackEnabled: boolean = true,
|
|
531
|
+
): JSONObject => {
|
|
532
|
+
/*
|
|
533
|
+
* The counts are sent deliberately wrong. parseReadinessSummary recomputes
|
|
534
|
+
* them from the rows, because a tile that disagrees with the list under it is
|
|
535
|
+
* worse than no tile at all.
|
|
536
|
+
*/
|
|
537
|
+
return {
|
|
538
|
+
projectId: "44444444-4444-4444-8444-444444444444",
|
|
539
|
+
onCallDutyPolicyId: POLICY_ID.toString(),
|
|
540
|
+
isFallbackEnabled: isFallbackEnabled,
|
|
541
|
+
readyCount: 0,
|
|
542
|
+
partiallyReadyCount: 0,
|
|
543
|
+
notReachableCount: 0,
|
|
544
|
+
users: users,
|
|
545
|
+
};
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const ALL_THREE_USERS: JSONArray = [READY_USER, PARTIAL_USER, UNREACHABLE_USER];
|
|
549
|
+
|
|
550
|
+
/*
|
|
551
|
+
* One PAGE of a summary. The endpoint is paged - default 100 responders, max
|
|
552
|
+
* 500 - and a client that reads one page and stops holds a prefix of the list
|
|
553
|
+
* while believing it holds the list: the responders past the cut appear in no
|
|
554
|
+
* count, no row and no chip, which is the feature's own central failure mode
|
|
555
|
+
* turned on the feature. `hasMore` is how the server says there is a cut.
|
|
556
|
+
*/
|
|
557
|
+
interface PageOptions {
|
|
558
|
+
users: JSONArray;
|
|
559
|
+
hasMore?: boolean | undefined;
|
|
560
|
+
totalCount?: number | undefined;
|
|
561
|
+
isTruncated?: boolean | undefined;
|
|
562
|
+
isFallbackEnabled?: boolean | undefined;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
type PageJsonFunction = (options: PageOptions) => JSONObject;
|
|
566
|
+
|
|
567
|
+
const pageJson: PageJsonFunction = (options: PageOptions): JSONObject => {
|
|
568
|
+
return {
|
|
569
|
+
...summaryJson(options.users, options.isFallbackEnabled ?? true),
|
|
570
|
+
hasMore: options.hasMore ?? false,
|
|
571
|
+
totalCount: options.totalCount ?? options.users.length,
|
|
572
|
+
isTruncated: options.isTruncated ?? false,
|
|
573
|
+
};
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
type RespondWithFunction = (payload: JSONObject) => void;
|
|
577
|
+
|
|
578
|
+
const respondWith: RespondWithFunction = (payload: JSONObject): void => {
|
|
579
|
+
getMock.mockResolvedValue(
|
|
580
|
+
new HTTPResponse<JSONObject>(200, payload, {}) as never,
|
|
581
|
+
);
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
// Each call gets the next page, in order, so a paging loop can be watched.
|
|
585
|
+
type RespondWithPagesFunction = (pages: Array<JSONObject>) => void;
|
|
586
|
+
|
|
587
|
+
const respondWithPages: RespondWithPagesFunction = (
|
|
588
|
+
pages: Array<JSONObject>,
|
|
589
|
+
): void => {
|
|
590
|
+
for (const page of pages) {
|
|
591
|
+
getMock.mockResolvedValueOnce(
|
|
592
|
+
new HTTPResponse<JSONObject>(200, page, {}) as never,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
// The url a given request was issued against, as a plain string.
|
|
598
|
+
type RequestUrlAtFunction = (index: number) => string;
|
|
599
|
+
|
|
600
|
+
const requestUrlAt: RequestUrlAtFunction = (index: number): string => {
|
|
601
|
+
const call: Array<unknown> = (getMock.mock.calls[index] ||
|
|
602
|
+
[]) as Array<unknown>;
|
|
603
|
+
const args: { url?: unknown } = (call[0] || {}) as { url?: unknown };
|
|
604
|
+
|
|
605
|
+
return String(args.url);
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
type RespondWithErrorFunction = () => void;
|
|
609
|
+
|
|
610
|
+
const respondWithError: RespondWithErrorFunction = (): void => {
|
|
611
|
+
getMock.mockResolvedValue(
|
|
612
|
+
new HTTPErrorResponse(500, { message: "boom" }, {}) as never,
|
|
613
|
+
);
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
type NeverRespondFunction = () => void;
|
|
617
|
+
|
|
618
|
+
const neverRespond: NeverRespondFunction = (): void => {
|
|
619
|
+
getMock.mockReturnValue(
|
|
620
|
+
new Promise((): void => {
|
|
621
|
+
// Deliberately never settles: this is the slow-request state.
|
|
622
|
+
}) as never,
|
|
623
|
+
);
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
type ToUserReadinessFunction = (json: JSONObject) => UserReadinessWire;
|
|
627
|
+
|
|
628
|
+
const toUserReadiness: ToUserReadinessFunction = (
|
|
629
|
+
json: JSONObject,
|
|
630
|
+
): UserReadinessWire => {
|
|
631
|
+
const parsed: UserReadinessWire | undefined = parseReadinessSummary(
|
|
632
|
+
summaryJson([json]),
|
|
633
|
+
).users[0];
|
|
634
|
+
|
|
635
|
+
if (!parsed) {
|
|
636
|
+
throw new Error("fixture did not parse");
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return parsed;
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
type RenderPolicyCardFunction = () => Promise<HTMLElement>;
|
|
643
|
+
|
|
644
|
+
const renderPolicyCard: RenderPolicyCardFunction =
|
|
645
|
+
async (): Promise<HTMLElement> => {
|
|
646
|
+
const { container } = render(
|
|
647
|
+
<ResponderReadinessCard onCallDutyPolicyId={POLICY_ID} />,
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
await waitFor((): void => {
|
|
651
|
+
expect(getMock).toHaveBeenCalled();
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
return container;
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
type RenderProjectPageFunction = () => Promise<HTMLElement>;
|
|
658
|
+
|
|
659
|
+
const renderProjectPage: RenderProjectPageFunction =
|
|
660
|
+
async (): Promise<HTMLElement> => {
|
|
661
|
+
const { container } = render(
|
|
662
|
+
<OnCallReadinessPage
|
|
663
|
+
pageRoute={new Route("/dashboard/on-call-duty/readiness")}
|
|
664
|
+
currentProject={null}
|
|
665
|
+
hasPaymentMethod={false}
|
|
666
|
+
/>,
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
await waitFor((): void => {
|
|
670
|
+
expect(getMock).toHaveBeenCalled();
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
return container;
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
/*
|
|
677
|
+
* Status wording is deliberately repeated between the summary tiles and the
|
|
678
|
+
* per-responder chips ("Unreachable" is both a tile label and a chip), so every
|
|
679
|
+
* status assertion is scoped to one responder's row rather than to the document.
|
|
680
|
+
* A document-wide query here would pass on the tile alone and never notice a
|
|
681
|
+
* chip that stopped rendering.
|
|
682
|
+
*/
|
|
683
|
+
type CardRowForFunction = (userName: string) => HTMLElement;
|
|
684
|
+
|
|
685
|
+
const cardRowFor: CardRowForFunction = (userName: string): HTMLElement => {
|
|
686
|
+
const row: HTMLElement | null = screen
|
|
687
|
+
.getByText(userName)
|
|
688
|
+
.closest("div.rounded-xl.border.p-4") as HTMLElement | null;
|
|
689
|
+
|
|
690
|
+
if (!row) {
|
|
691
|
+
throw new Error(`no readiness row for ${userName}`);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
return row;
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
type TableRowForFunction = (userName: string) => HTMLElement;
|
|
698
|
+
|
|
699
|
+
const tableRowFor: TableRowForFunction = (userName: string): HTMLElement => {
|
|
700
|
+
const row: HTMLElement | null = screen
|
|
701
|
+
.getByText(userName)
|
|
702
|
+
.closest("tr") as HTMLElement | null;
|
|
703
|
+
|
|
704
|
+
if (!row) {
|
|
705
|
+
throw new Error(`no table row for ${userName}`);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return row;
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
/*
|
|
712
|
+
* The coverage grid lives in a modal, opened from the row's own action button.
|
|
713
|
+
*
|
|
714
|
+
* It used to expand the row in place, which the shared Table has no notion of -
|
|
715
|
+
* and a grid that is severities-down by rule-types-across pushed every row under
|
|
716
|
+
* it off the screen anyway.
|
|
717
|
+
*/
|
|
718
|
+
type OpenCoverageFunction = (userName: string) => void;
|
|
719
|
+
|
|
720
|
+
const openCoverage: OpenCoverageFunction = (userName: string): void => {
|
|
721
|
+
fireEvent.click(
|
|
722
|
+
within(tableRowFor(userName)).getByRole("button", { name: "Coverage" }),
|
|
723
|
+
);
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
/*
|
|
727
|
+
* Ticking one responder. The checkbox's accessible name is what the table builds
|
|
728
|
+
* from bulkItemToString, so this is also the assertion that every row's box is
|
|
729
|
+
* addressable at all - a column of unnamed boxes is one a screen reader user
|
|
730
|
+
* cannot tell apart.
|
|
731
|
+
*/
|
|
732
|
+
type SelectResponderFunction = (userName: string) => void;
|
|
733
|
+
|
|
734
|
+
const selectResponder: SelectResponderFunction = (userName: string): void => {
|
|
735
|
+
fireEvent.click(screen.getByLabelText(`Select ${userName}`));
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
/*
|
|
739
|
+
* Opening the bulk-actions menu and pressing one of its items. Two clicks
|
|
740
|
+
* because that is what the shared bulk bar is: a selection count, a menu, and
|
|
741
|
+
* the actions inside it.
|
|
742
|
+
*/
|
|
743
|
+
type RunBulkActionFunction = (title: string) => void;
|
|
744
|
+
|
|
745
|
+
const runBulkAction: RunBulkActionFunction = (title: string): void => {
|
|
746
|
+
fireEvent.click(screen.getByText("Bulk Actions"));
|
|
747
|
+
fireEvent.click(screen.getByRole("menuitem", { name: title }));
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
/** The filter modal, opened from the funnel button in the card header. */
|
|
751
|
+
type OpenFilterModalFunction = () => void;
|
|
752
|
+
|
|
753
|
+
const openFilterModal: OpenFilterModalFunction = (): void => {
|
|
754
|
+
fireEvent.click(screen.getByRole("button", { name: "Filter" }));
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
/*
|
|
758
|
+
* The setup-reminder endpoint's answer. Deliberately takes the per-user results
|
|
759
|
+
* and DERIVES nothing: the payload a test writes is the payload the page gets,
|
|
760
|
+
* so a test can hand back a self-contradictory body (counts that disagree with
|
|
761
|
+
* the list) and watch the page refuse to be confused by it.
|
|
762
|
+
*/
|
|
763
|
+
interface ReminderOutcomeFixture {
|
|
764
|
+
userId: string;
|
|
765
|
+
outcome: string;
|
|
766
|
+
message: string;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
type ReminderJsonFunction = (
|
|
770
|
+
results: Array<ReminderOutcomeFixture>,
|
|
771
|
+
) => JSONObject;
|
|
772
|
+
|
|
773
|
+
const reminderJson: ReminderJsonFunction = (
|
|
774
|
+
results: Array<ReminderOutcomeFixture>,
|
|
775
|
+
): JSONObject => {
|
|
776
|
+
return {
|
|
777
|
+
projectId: "44444444-4444-4444-8444-444444444444",
|
|
778
|
+
/*
|
|
779
|
+
* Wrong on purpose, exactly like summaryJson's counts. The banner recomputes
|
|
780
|
+
* from `results` so its headline can never contradict the list underneath
|
|
781
|
+
* it.
|
|
782
|
+
*/
|
|
783
|
+
requestedCount: 0,
|
|
784
|
+
sentCount: 0,
|
|
785
|
+
skippedCount: 0,
|
|
786
|
+
failedCount: 0,
|
|
787
|
+
results: results as unknown as JSONArray,
|
|
788
|
+
};
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
type RespondToReminderFunction = (payload: JSONObject) => void;
|
|
792
|
+
|
|
793
|
+
const respondToReminderWith: RespondToReminderFunction = (
|
|
794
|
+
payload: JSONObject,
|
|
795
|
+
): void => {
|
|
796
|
+
postMock.mockResolvedValue(
|
|
797
|
+
new HTTPResponse<JSONObject>(200, payload, {}) as never,
|
|
798
|
+
);
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
beforeEach((): void => {
|
|
802
|
+
getMock.mockReset();
|
|
803
|
+
postMock.mockReset();
|
|
804
|
+
postMock.mockResolvedValue(
|
|
805
|
+
new HTTPResponse<JSONObject>(200, reminderJson([]), {}) as never,
|
|
806
|
+
);
|
|
807
|
+
getCommonHeadersMock.mockReset();
|
|
808
|
+
getCommonHeadersMock.mockReturnValue({} as never);
|
|
809
|
+
localStorage.clear();
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
afterEach((): void => {
|
|
813
|
+
cleanup();
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
describe("ReadinessDot", () => {
|
|
817
|
+
/*
|
|
818
|
+
* Absence is the "ready" signal. A dot on every healthy chip puts a mark on
|
|
819
|
+
* the majority of chips in a healthy policy, and the eye then has to read
|
|
820
|
+
* every one of them to learn that nothing is wrong.
|
|
821
|
+
*/
|
|
822
|
+
test("a ready responder gets no dot at all", () => {
|
|
823
|
+
const { container } = render(
|
|
824
|
+
<ReadinessDot
|
|
825
|
+
user={toUserReadiness(READY_USER)}
|
|
826
|
+
label="Ada Ready"
|
|
827
|
+
isFallbackEnabled={true}
|
|
828
|
+
/>,
|
|
829
|
+
);
|
|
830
|
+
|
|
831
|
+
expect(container.innerHTML).toBe("");
|
|
832
|
+
expect(container.querySelector("span")).toBeNull();
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
/*
|
|
836
|
+
* Absence has to be absence in both worlds. A project with the fallback off is
|
|
837
|
+
* a project where every gap is a dropped page, and the temptation is to start
|
|
838
|
+
* marking everything - but a ready responder has no gap, so a dot on their chip
|
|
839
|
+
* would be an alarm with no referent and would put a mark on the majority of
|
|
840
|
+
* chips all over again.
|
|
841
|
+
*/
|
|
842
|
+
test("a ready responder gets no dot even where pages are being dropped", () => {
|
|
843
|
+
const { container } = render(
|
|
844
|
+
<ReadinessDot
|
|
845
|
+
user={toUserReadiness(READY_USER)}
|
|
846
|
+
label="Ada Ready"
|
|
847
|
+
isFallbackEnabled={false}
|
|
848
|
+
/>,
|
|
849
|
+
);
|
|
850
|
+
|
|
851
|
+
expect(container.innerHTML).toBe("");
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
test("a partially ready responder gets an amber dot", () => {
|
|
855
|
+
const { container } = render(
|
|
856
|
+
<ReadinessDot
|
|
857
|
+
user={toUserReadiness(PARTIAL_USER)}
|
|
858
|
+
label="Jane Partial"
|
|
859
|
+
isFallbackEnabled={true}
|
|
860
|
+
/>,
|
|
861
|
+
);
|
|
862
|
+
|
|
863
|
+
expect(container.querySelector(".bg-amber-500")).not.toBeNull();
|
|
864
|
+
expect(container.querySelector(".bg-red-500")).toBeNull();
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
test("an unreachable responder gets a red dot", () => {
|
|
868
|
+
const { container } = render(
|
|
869
|
+
<ReadinessDot
|
|
870
|
+
user={toUserReadiness(UNREACHABLE_USER)}
|
|
871
|
+
label="Zed Unreachable"
|
|
872
|
+
isFallbackEnabled={true}
|
|
873
|
+
/>,
|
|
874
|
+
);
|
|
875
|
+
|
|
876
|
+
expect(container.querySelector(".bg-red-500")).not.toBeNull();
|
|
877
|
+
expect(container.querySelector(".bg-amber-500")).toBeNull();
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
/*
|
|
881
|
+
* Amber is degraded, not dropped - Phase 1's fallback still pages these
|
|
882
|
+
* people. A tooltip that implies the page vanishes is how the old binary pill
|
|
883
|
+
* lost its audience, so amber has to promise delivery and red has to say the
|
|
884
|
+
* page is lost.
|
|
885
|
+
*/
|
|
886
|
+
test("the amber tooltip promises delivery and the red one does not", () => {
|
|
887
|
+
const { container: amber } = render(
|
|
888
|
+
<ReadinessDot
|
|
889
|
+
user={toUserReadiness(PARTIAL_USER)}
|
|
890
|
+
label="Jane Partial"
|
|
891
|
+
isFallbackEnabled={true}
|
|
892
|
+
/>,
|
|
893
|
+
);
|
|
894
|
+
|
|
895
|
+
const amberLabel: string =
|
|
896
|
+
amber.querySelector("[aria-label]")?.getAttribute("aria-label") || "";
|
|
897
|
+
|
|
898
|
+
expect(amberLabel).toContain("still reach them");
|
|
899
|
+
expect(amberLabel).toContain("so nothing is dropped");
|
|
900
|
+
|
|
901
|
+
cleanup();
|
|
902
|
+
|
|
903
|
+
const { container: red } = render(
|
|
904
|
+
<ReadinessDot
|
|
905
|
+
user={toUserReadiness(UNREACHABLE_USER)}
|
|
906
|
+
label="Zed Unreachable"
|
|
907
|
+
isFallbackEnabled={true}
|
|
908
|
+
/>,
|
|
909
|
+
);
|
|
910
|
+
|
|
911
|
+
const redLabel: string =
|
|
912
|
+
red.querySelector("[aria-label]")?.getAttribute("aria-label") || "";
|
|
913
|
+
|
|
914
|
+
expect(redLabel).toContain("every page routed to them is dropped");
|
|
915
|
+
expect(redLabel).not.toContain("still reach");
|
|
916
|
+
expect(redLabel).toContain("Zed Unreachable");
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
/*
|
|
920
|
+
* The promise the old copy made unconditionally. With the fallback switched
|
|
921
|
+
* off there is nothing behind a rule gap at all: no rule means no notification,
|
|
922
|
+
* and telling an admin "nothing is dropped" here is telling them their problem
|
|
923
|
+
* is harmless at the exact moment it is not.
|
|
924
|
+
*/
|
|
925
|
+
test("the amber tooltip stops promising delivery when the fallback is off", () => {
|
|
926
|
+
const { container } = render(
|
|
927
|
+
<ReadinessDot
|
|
928
|
+
user={toUserReadiness(PARTIAL_USER)}
|
|
929
|
+
label="Jane Partial"
|
|
930
|
+
isFallbackEnabled={false}
|
|
931
|
+
/>,
|
|
932
|
+
);
|
|
933
|
+
|
|
934
|
+
const label: string =
|
|
935
|
+
container.querySelector("[aria-label]")?.getAttribute("aria-label") || "";
|
|
936
|
+
|
|
937
|
+
expect(label).toContain("fallback switched off");
|
|
938
|
+
expect(label).toContain("dropped");
|
|
939
|
+
expect(label).not.toContain("nothing is dropped");
|
|
940
|
+
expect(label).not.toContain("still reach them");
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
// Amber stays amber: the state did not change, only what it costs.
|
|
944
|
+
test("the fallback switch changes the wording, not the colour", () => {
|
|
945
|
+
const { container } = render(
|
|
946
|
+
<ReadinessDot
|
|
947
|
+
user={toUserReadiness(PARTIAL_USER)}
|
|
948
|
+
label="Jane Partial"
|
|
949
|
+
isFallbackEnabled={false}
|
|
950
|
+
/>,
|
|
951
|
+
);
|
|
952
|
+
|
|
953
|
+
expect(container.querySelector(".bg-amber-500")).not.toBeNull();
|
|
954
|
+
expect(container.querySelector(".bg-red-500")).toBeNull();
|
|
955
|
+
});
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
/*
|
|
959
|
+
* The escalation summary's chips, which are where an admin is looking when they
|
|
960
|
+
* are actually editing who gets paged.
|
|
961
|
+
*
|
|
962
|
+
* These assertions exist because the dots were dead code: the summary's only
|
|
963
|
+
* caller passed neither the policy id nor a userId on the responders it built,
|
|
964
|
+
* so the readiness hook stayed idle, no request was ever issued, and no dot could
|
|
965
|
+
* render under any circumstances. Every test below fails if either half of that
|
|
966
|
+
* wiring comes loose again - the request test catches a dropped policy id, and
|
|
967
|
+
* the by-id test catches responders rebuilt without one.
|
|
968
|
+
*/
|
|
969
|
+
describe("the escalation summary's readiness dots", () => {
|
|
970
|
+
type LevelWithFunction = (
|
|
971
|
+
responders: Array<EscalationResponder>,
|
|
972
|
+
) => Array<EscalationLevelSummary>;
|
|
973
|
+
|
|
974
|
+
const levelWith: LevelWithFunction = (
|
|
975
|
+
responders: Array<EscalationResponder>,
|
|
976
|
+
): Array<EscalationLevelSummary> => {
|
|
977
|
+
return [
|
|
978
|
+
{
|
|
979
|
+
name: "Level 1",
|
|
980
|
+
escalateAfterInMinutes: 0,
|
|
981
|
+
responders: responders,
|
|
982
|
+
},
|
|
983
|
+
];
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
interface RenderSummaryOptions {
|
|
987
|
+
responders: Array<EscalationResponder>;
|
|
988
|
+
onCallDutyPolicyId?: ObjectID | undefined;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
type RenderSummaryFunction = (options: RenderSummaryOptions) => void;
|
|
992
|
+
|
|
993
|
+
const renderSummary: RenderSummaryFunction = (
|
|
994
|
+
options: RenderSummaryOptions,
|
|
995
|
+
): void => {
|
|
996
|
+
render(
|
|
997
|
+
<EscalationSummary
|
|
998
|
+
levels={levelWith(options.responders)}
|
|
999
|
+
repeatEnabled={false}
|
|
1000
|
+
repeatCount={0}
|
|
1001
|
+
onCallDutyPolicyId={options.onCallDutyPolicyId}
|
|
1002
|
+
/>,
|
|
1003
|
+
);
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
/*
|
|
1007
|
+
* The chip is the element whose own text is the responder label - the icon and
|
|
1008
|
+
* the dot are element children, so they do not interfere with the match.
|
|
1009
|
+
*/
|
|
1010
|
+
type ChipForFunction = (label: string) => HTMLElement;
|
|
1011
|
+
|
|
1012
|
+
const chipFor: ChipForFunction = (label: string): HTMLElement => {
|
|
1013
|
+
const chip: HTMLElement | null = screen
|
|
1014
|
+
.getByText(label)
|
|
1015
|
+
.closest("span") as HTMLElement | null;
|
|
1016
|
+
|
|
1017
|
+
if (!chip) {
|
|
1018
|
+
throw new Error(`no responder chip for ${label}`);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
return chip;
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
// The dot is the only element in a chip that exposes itself as an image.
|
|
1025
|
+
type DotInFunction = (chip: HTMLElement) => HTMLElement | null;
|
|
1026
|
+
|
|
1027
|
+
const dotIn: DotInFunction = (chip: HTMLElement): HTMLElement | null => {
|
|
1028
|
+
return within(chip).queryByRole("img");
|
|
1029
|
+
};
|
|
1030
|
+
|
|
1031
|
+
const userChip: (label: string, userId: string) => EscalationResponder = (
|
|
1032
|
+
label: string,
|
|
1033
|
+
userId: string,
|
|
1034
|
+
): EscalationResponder => {
|
|
1035
|
+
return { type: "user", label: label, userId: userId };
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
test("issues a readiness request for the policy it was given", async () => {
|
|
1039
|
+
respondWith(summaryJson([PARTIAL_USER]));
|
|
1040
|
+
|
|
1041
|
+
renderSummary({
|
|
1042
|
+
responders: [userChip("Jane Partial", PARTIAL_USER_ID)],
|
|
1043
|
+
onCallDutyPolicyId: POLICY_ID,
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
await waitFor((): void => {
|
|
1047
|
+
expect(getMock).toHaveBeenCalled();
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
expect(requestUrlAt(0)).toContain(
|
|
1051
|
+
`/on-call-readiness/policy/${POLICY_ID.toString()}`,
|
|
1052
|
+
);
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
/*
|
|
1056
|
+
* The exact shape of the original bug, kept as a test: a summary with no policy
|
|
1057
|
+
* id is inert. That is correct behaviour for a caller that has not opted in,
|
|
1058
|
+
* and it is precisely why the real caller failing to opt in produced no error,
|
|
1059
|
+
* no request and no dot - nothing to notice.
|
|
1060
|
+
*/
|
|
1061
|
+
test("stays idle, and dotless, without a policy id", async () => {
|
|
1062
|
+
respondWith(summaryJson([PARTIAL_USER]));
|
|
1063
|
+
|
|
1064
|
+
renderSummary({
|
|
1065
|
+
responders: [userChip("Jane Partial", PARTIAL_USER_ID)],
|
|
1066
|
+
onCallDutyPolicyId: undefined,
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
expect(getMock).not.toHaveBeenCalled();
|
|
1070
|
+
expect(dotIn(chipFor("Jane Partial"))).toBeNull();
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
test("marks the amber and red chips and leaves the ready one clean", async () => {
|
|
1074
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
1075
|
+
|
|
1076
|
+
renderSummary({
|
|
1077
|
+
responders: [
|
|
1078
|
+
userChip("Ada Ready", READY_USER_ID),
|
|
1079
|
+
userChip("Jane Partial", PARTIAL_USER_ID),
|
|
1080
|
+
userChip("Zed Unreachable", UNREACHABLE_USER_ID),
|
|
1081
|
+
],
|
|
1082
|
+
onCallDutyPolicyId: POLICY_ID,
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
await waitFor((): void => {
|
|
1086
|
+
expect(dotIn(chipFor("Jane Partial"))).not.toBeNull();
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
expect(
|
|
1090
|
+
chipFor("Jane Partial").querySelector(".bg-amber-500"),
|
|
1091
|
+
).not.toBeNull();
|
|
1092
|
+
expect(
|
|
1093
|
+
chipFor("Zed Unreachable").querySelector(".bg-red-500"),
|
|
1094
|
+
).not.toBeNull();
|
|
1095
|
+
|
|
1096
|
+
// Absence is the "ready" signal, and it has to survive a loaded payload.
|
|
1097
|
+
expect(dotIn(chipFor("Ada Ready"))).toBeNull();
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
/*
|
|
1101
|
+
* The chip label is whatever the escalation rule renders - a display name that
|
|
1102
|
+
* two people can share. The id is what makes the match reliable, so a chip
|
|
1103
|
+
* labelled differently from the readiness row must still find its row.
|
|
1104
|
+
*/
|
|
1105
|
+
test("matches a chip to its row by id, not by label", async () => {
|
|
1106
|
+
respondWith(summaryJson([PARTIAL_USER]));
|
|
1107
|
+
|
|
1108
|
+
renderSummary({
|
|
1109
|
+
responders: [userChip("J. Partial (on-call)", PARTIAL_USER_ID)],
|
|
1110
|
+
onCallDutyPolicyId: POLICY_ID,
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
await waitFor((): void => {
|
|
1114
|
+
expect(dotIn(chipFor("J. Partial (on-call)"))).not.toBeNull();
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
// And the tooltip names the person the reader is actually pointing at.
|
|
1118
|
+
expect(
|
|
1119
|
+
dotIn(chipFor("J. Partial (on-call)"))?.getAttribute("aria-label"),
|
|
1120
|
+
).toContain("J. Partial (on-call)");
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
/*
|
|
1124
|
+
* Teams and schedules are containers. The payload is per-user and never says
|
|
1125
|
+
* which team a user was reached through, so a dot on a team chip could only be
|
|
1126
|
+
* a guess - and a guess that happens to be wrong sends an admin to fix an
|
|
1127
|
+
* account that was never broken.
|
|
1128
|
+
*/
|
|
1129
|
+
test("never marks a team chip, even one named after a person", async () => {
|
|
1130
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
1131
|
+
|
|
1132
|
+
renderSummary({
|
|
1133
|
+
responders: [
|
|
1134
|
+
{ type: "team", label: "Jane Partial" },
|
|
1135
|
+
userChip("Zed Unreachable", UNREACHABLE_USER_ID),
|
|
1136
|
+
],
|
|
1137
|
+
onCallDutyPolicyId: POLICY_ID,
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
await waitFor((): void => {
|
|
1141
|
+
expect(dotIn(chipFor("Zed Unreachable"))).not.toBeNull();
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
expect(dotIn(chipFor("Jane Partial"))).toBeNull();
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
/*
|
|
1148
|
+
* A responder the readiness answer does not cover must not be drawn the way a
|
|
1149
|
+
* healthy responder is drawn. Everywhere else on this row a bare chip means
|
|
1150
|
+
* "ready", so silence here would promote somebody nobody checked into somebody
|
|
1151
|
+
* who is fine - the substitution the whole feature exists to prevent.
|
|
1152
|
+
*/
|
|
1153
|
+
test("marks an unchecked responder as unknown when the answer is incomplete", async () => {
|
|
1154
|
+
respondWith(
|
|
1155
|
+
pageJson({
|
|
1156
|
+
users: [PARTIAL_USER],
|
|
1157
|
+
isTruncated: true,
|
|
1158
|
+
totalCount: 4000,
|
|
1159
|
+
}),
|
|
1160
|
+
);
|
|
1161
|
+
|
|
1162
|
+
renderSummary({
|
|
1163
|
+
responders: [
|
|
1164
|
+
userChip("Jane Partial", PARTIAL_USER_ID),
|
|
1165
|
+
userChip("Ada Ready", READY_USER_ID),
|
|
1166
|
+
],
|
|
1167
|
+
onCallDutyPolicyId: POLICY_ID,
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
await waitFor((): void => {
|
|
1171
|
+
expect(dotIn(chipFor("Ada Ready"))).not.toBeNull();
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
// Grey, not amber: an unknown is not an accusation.
|
|
1175
|
+
expect(chipFor("Ada Ready").querySelector(".bg-gray-400")).not.toBeNull();
|
|
1176
|
+
expect(chipFor("Ada Ready").querySelector(".bg-amber-500")).toBeNull();
|
|
1177
|
+
expect(
|
|
1178
|
+
dotIn(chipFor("Ada Ready"))?.getAttribute("aria-label") || "",
|
|
1179
|
+
).toContain("was not checked");
|
|
1180
|
+
|
|
1181
|
+
// The responder the answer DOES cover keeps its real state.
|
|
1182
|
+
expect(
|
|
1183
|
+
chipFor("Jane Partial").querySelector(".bg-amber-500"),
|
|
1184
|
+
).not.toBeNull();
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
/*
|
|
1188
|
+
* The complement, and the more important half: on a complete answer a chip
|
|
1189
|
+
* with no row is a chip for somebody who is genuinely ready, and marking it
|
|
1190
|
+
* would put a dot on the majority of chips in a healthy policy.
|
|
1191
|
+
*/
|
|
1192
|
+
test("leaves an unmatched chip bare when the answer is complete", async () => {
|
|
1193
|
+
respondWith(pageJson({ users: [PARTIAL_USER] }));
|
|
1194
|
+
|
|
1195
|
+
renderSummary({
|
|
1196
|
+
responders: [
|
|
1197
|
+
userChip("Jane Partial", PARTIAL_USER_ID),
|
|
1198
|
+
userChip("Ada Ready", READY_USER_ID),
|
|
1199
|
+
],
|
|
1200
|
+
onCallDutyPolicyId: POLICY_ID,
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
await waitFor((): void => {
|
|
1204
|
+
expect(dotIn(chipFor("Jane Partial"))).not.toBeNull();
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
expect(dotIn(chipFor("Ada Ready"))).toBeNull();
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
test("the chip tooltip follows the project's fallback switch", async () => {
|
|
1211
|
+
respondWith(summaryJson([PARTIAL_USER], false));
|
|
1212
|
+
|
|
1213
|
+
renderSummary({
|
|
1214
|
+
responders: [userChip("Jane Partial", PARTIAL_USER_ID)],
|
|
1215
|
+
onCallDutyPolicyId: POLICY_ID,
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
await waitFor((): void => {
|
|
1219
|
+
expect(dotIn(chipFor("Jane Partial"))).not.toBeNull();
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
const label: string =
|
|
1223
|
+
dotIn(chipFor("Jane Partial"))?.getAttribute("aria-label") || "";
|
|
1224
|
+
|
|
1225
|
+
expect(label).toContain("fallback switched off");
|
|
1226
|
+
expect(label).not.toContain("nothing is dropped");
|
|
1227
|
+
});
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
describe("ResponderReadinessCard", () => {
|
|
1231
|
+
/*
|
|
1232
|
+
* A slow request must keep the card's shape. Swapping the whole body for a
|
|
1233
|
+
* spinner - which is what the compliance table did - reads as "this feature is
|
|
1234
|
+
* broken" rather than "this is still loading", because the card's structure
|
|
1235
|
+
* disappears along with its content.
|
|
1236
|
+
*/
|
|
1237
|
+
describe("while loading", () => {
|
|
1238
|
+
test("draws skeletons rather than blanking the card", async () => {
|
|
1239
|
+
neverRespond();
|
|
1240
|
+
|
|
1241
|
+
const container: HTMLElement = await renderPolicyCard();
|
|
1242
|
+
|
|
1243
|
+
expect(
|
|
1244
|
+
container.querySelectorAll(".animate-pulse").length,
|
|
1245
|
+
).toBeGreaterThan(0);
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
test("keeps the heading and the explanation on screen", async () => {
|
|
1249
|
+
neverRespond();
|
|
1250
|
+
|
|
1251
|
+
await renderPolicyCard();
|
|
1252
|
+
|
|
1253
|
+
expect(screen.getByText("Responder readiness")).toBeInTheDocument();
|
|
1254
|
+
expect(
|
|
1255
|
+
screen.getByText(/including people it reaches through a team/),
|
|
1256
|
+
).toBeInTheDocument();
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
test("does not render an error or an empty state while it waits", async () => {
|
|
1260
|
+
neverRespond();
|
|
1261
|
+
|
|
1262
|
+
await renderPolicyCard();
|
|
1263
|
+
|
|
1264
|
+
expect(screen.queryByText("Could not load readiness")).toBeNull();
|
|
1265
|
+
expect(screen.queryByText(/No one is on this policy yet/)).toBeNull();
|
|
1266
|
+
});
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
describe("when the request fails", () => {
|
|
1270
|
+
test("renders the failure and a way to retry", async () => {
|
|
1271
|
+
respondWithError();
|
|
1272
|
+
|
|
1273
|
+
await renderPolicyCard();
|
|
1274
|
+
|
|
1275
|
+
expect(
|
|
1276
|
+
await screen.findByText("Could not load readiness"),
|
|
1277
|
+
).toBeInTheDocument();
|
|
1278
|
+
expect(screen.getByRole("refresh-button")).toBeInTheDocument();
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
test("retrying issues another request", async () => {
|
|
1282
|
+
respondWithError();
|
|
1283
|
+
|
|
1284
|
+
await renderPolicyCard();
|
|
1285
|
+
|
|
1286
|
+
await screen.findByText("Could not load readiness");
|
|
1287
|
+
|
|
1288
|
+
const callsBefore: number = getMock.mock.calls.length;
|
|
1289
|
+
|
|
1290
|
+
fireEvent.click(screen.getByRole("refresh-button"));
|
|
1291
|
+
|
|
1292
|
+
await waitFor((): void => {
|
|
1293
|
+
expect(getMock.mock.calls.length).toBeGreaterThan(callsBefore);
|
|
1294
|
+
});
|
|
1295
|
+
});
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
describe("when nobody is on the policy", () => {
|
|
1299
|
+
test("explains why the card is empty and where to fix it", async () => {
|
|
1300
|
+
respondWith(summaryJson([]));
|
|
1301
|
+
|
|
1302
|
+
await renderPolicyCard();
|
|
1303
|
+
|
|
1304
|
+
expect(
|
|
1305
|
+
await screen.findByText(/No one is on this policy yet/),
|
|
1306
|
+
).toBeInTheDocument();
|
|
1307
|
+
expect(screen.getByText("Escalation")).toBeInTheDocument();
|
|
1308
|
+
});
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
describe("when everyone is ready", () => {
|
|
1312
|
+
/*
|
|
1313
|
+
* The positive confirmation lives here precisely because the chips carry no
|
|
1314
|
+
* green dot. If this card were silent too, "ready" would have no surface at
|
|
1315
|
+
* all and the absence of a dot would be indistinguishable from a bug.
|
|
1316
|
+
*/
|
|
1317
|
+
test("says so positively instead of showing an empty list", async () => {
|
|
1318
|
+
respondWith(summaryJson([READY_USER]));
|
|
1319
|
+
|
|
1320
|
+
await renderPolicyCard();
|
|
1321
|
+
|
|
1322
|
+
expect(
|
|
1323
|
+
await screen.findByText("Every responder can be paged"),
|
|
1324
|
+
).toBeInTheDocument();
|
|
1325
|
+
expect(screen.queryByText("Needs attention")).toBeNull();
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
test("counts the one responder in its tiles", async () => {
|
|
1329
|
+
respondWith(summaryJson([READY_USER]));
|
|
1330
|
+
|
|
1331
|
+
await renderPolicyCard();
|
|
1332
|
+
|
|
1333
|
+
await screen.findByText("Every responder can be paged");
|
|
1334
|
+
|
|
1335
|
+
// Singular label, and the ready tile counting the one ready responder.
|
|
1336
|
+
expect(screen.getByText("Responder")).toBeInTheDocument();
|
|
1337
|
+
expect(screen.getByText("Ready")).toBeInTheDocument();
|
|
1338
|
+
});
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
describe("the three states, rendered together", () => {
|
|
1342
|
+
test("each non-ready row carries its own status wording", async () => {
|
|
1343
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
1344
|
+
|
|
1345
|
+
await renderPolicyCard();
|
|
1346
|
+
|
|
1347
|
+
await screen.findByText("Zed Unreachable");
|
|
1348
|
+
|
|
1349
|
+
expect(
|
|
1350
|
+
within(cardRowFor("Zed Unreachable")).getByText("Unreachable"),
|
|
1351
|
+
).toBeInTheDocument();
|
|
1352
|
+
expect(
|
|
1353
|
+
within(cardRowFor("Jane Partial")).getByText(PARTIAL_GAP_LABEL),
|
|
1354
|
+
).toBeInTheDocument();
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
/*
|
|
1358
|
+
* Red and amber are different colours on purpose: red loses pages, amber
|
|
1359
|
+
* does not. Collapsing them into one alarming colour is how a status surface
|
|
1360
|
+
* trains its readers to ignore it.
|
|
1361
|
+
*/
|
|
1362
|
+
test("red and amber rows are visually distinct", async () => {
|
|
1363
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
1364
|
+
|
|
1365
|
+
const container: HTMLElement = await renderPolicyCard();
|
|
1366
|
+
|
|
1367
|
+
await screen.findByText("Zed Unreachable");
|
|
1368
|
+
|
|
1369
|
+
expect(container.querySelector(".border-red-200")).not.toBeNull();
|
|
1370
|
+
expect(container.querySelector(".border-amber-200")).not.toBeNull();
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
test("ready responders are not listed under Needs attention", async () => {
|
|
1374
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
1375
|
+
|
|
1376
|
+
await renderPolicyCard();
|
|
1377
|
+
|
|
1378
|
+
await screen.findByText("Zed Unreachable");
|
|
1379
|
+
|
|
1380
|
+
expect(screen.getByText("Needs attention")).toBeInTheDocument();
|
|
1381
|
+
expect(screen.getByText("2 of 3")).toBeInTheDocument();
|
|
1382
|
+
expect(screen.queryByText("Ada Ready")).toBeNull();
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
test("the amber row states that its pages still arrive", async () => {
|
|
1386
|
+
respondWith(summaryJson([PARTIAL_USER]));
|
|
1387
|
+
|
|
1388
|
+
await renderPolicyCard();
|
|
1389
|
+
|
|
1390
|
+
await screen.findByText("Jane Partial");
|
|
1391
|
+
|
|
1392
|
+
expect(cardRowFor("Jane Partial").textContent).toContain(
|
|
1393
|
+
"so nothing is dropped",
|
|
1394
|
+
);
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
test("the red row states that its pages are dropped", async () => {
|
|
1398
|
+
respondWith(summaryJson([UNREACHABLE_USER]));
|
|
1399
|
+
|
|
1400
|
+
await renderPolicyCard();
|
|
1401
|
+
|
|
1402
|
+
await screen.findByText("Zed Unreachable");
|
|
1403
|
+
|
|
1404
|
+
const row: HTMLElement = cardRowFor("Zed Unreachable");
|
|
1405
|
+
|
|
1406
|
+
expect(row.textContent).toContain("is dropped");
|
|
1407
|
+
expect(row.textContent).toContain(
|
|
1408
|
+
"No verified notification method on this account.",
|
|
1409
|
+
);
|
|
1410
|
+
});
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
/*
|
|
1414
|
+
* The endpoint is paged, and this card counts people. A card that read one
|
|
1415
|
+
* page would print "0 unreachable" over a policy whose unreachable responders
|
|
1416
|
+
* happened to sort onto page two - a reassuring number computed from part of a
|
|
1417
|
+
* list, which is worse than no number at all.
|
|
1418
|
+
*/
|
|
1419
|
+
describe("when the answer spans more than one page", () => {
|
|
1420
|
+
test("reads every page before it counts anything", async () => {
|
|
1421
|
+
respondWithPages([
|
|
1422
|
+
pageJson({
|
|
1423
|
+
users: [READY_USER, PARTIAL_USER],
|
|
1424
|
+
hasMore: true,
|
|
1425
|
+
totalCount: 3,
|
|
1426
|
+
}),
|
|
1427
|
+
pageJson({ users: [UNREACHABLE_USER], hasMore: false, totalCount: 3 }),
|
|
1428
|
+
]);
|
|
1429
|
+
|
|
1430
|
+
await renderPolicyCard();
|
|
1431
|
+
|
|
1432
|
+
// The row from page two is on screen, so the second page was read.
|
|
1433
|
+
expect(await screen.findByText("Zed Unreachable")).toBeInTheDocument();
|
|
1434
|
+
expect(screen.getByText("Jane Partial")).toBeInTheDocument();
|
|
1435
|
+
expect(screen.getByText("2 of 3")).toBeInTheDocument();
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
test("asks for the next page from where the last one ended", async () => {
|
|
1439
|
+
respondWithPages([
|
|
1440
|
+
pageJson({
|
|
1441
|
+
users: [READY_USER, PARTIAL_USER],
|
|
1442
|
+
hasMore: true,
|
|
1443
|
+
totalCount: 3,
|
|
1444
|
+
}),
|
|
1445
|
+
pageJson({ users: [UNREACHABLE_USER], hasMore: false, totalCount: 3 }),
|
|
1446
|
+
]);
|
|
1447
|
+
|
|
1448
|
+
await renderPolicyCard();
|
|
1449
|
+
|
|
1450
|
+
await screen.findByText("Zed Unreachable");
|
|
1451
|
+
|
|
1452
|
+
expect(getMock.mock.calls.length).toBe(2);
|
|
1453
|
+
expect(requestUrlAt(0)).toContain("skip=0");
|
|
1454
|
+
expect(requestUrlAt(1)).toContain("skip=2");
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
test("stops at the last page rather than asking forever", async () => {
|
|
1458
|
+
respondWith(pageJson({ users: ALL_THREE_USERS, hasMore: false }));
|
|
1459
|
+
|
|
1460
|
+
await renderPolicyCard();
|
|
1461
|
+
|
|
1462
|
+
await screen.findByText("Zed Unreachable");
|
|
1463
|
+
|
|
1464
|
+
expect(getMock.mock.calls.length).toBe(1);
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
/*
|
|
1468
|
+
* A server that says "there is more" and returns nothing would otherwise
|
|
1469
|
+
* re-request the same offset until the page cap. Stopping is not enough on
|
|
1470
|
+
* its own - the card has to admit it is holding part of a list.
|
|
1471
|
+
*/
|
|
1472
|
+
test("gives up, and says so, on a page that cannot advance", async () => {
|
|
1473
|
+
respondWith(pageJson({ users: [], hasMore: true, totalCount: 900 }));
|
|
1474
|
+
|
|
1475
|
+
await renderPolicyCard();
|
|
1476
|
+
|
|
1477
|
+
expect(
|
|
1478
|
+
await screen.findByText(/more than this card reads at once/),
|
|
1479
|
+
).toBeInTheDocument();
|
|
1480
|
+
expect(getMock.mock.calls.length).toBe(1);
|
|
1481
|
+
});
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
/*
|
|
1485
|
+
* The server has its own truncation, from its own read ceilings, and it is a
|
|
1486
|
+
* different claim: not "you are holding part of a list" but "the answer itself
|
|
1487
|
+
* is missing people". Either way a count becomes a floor, and the card has to
|
|
1488
|
+
* say which one it is rather than printing a confident total.
|
|
1489
|
+
*/
|
|
1490
|
+
describe("when the server reports its own answer is incomplete", () => {
|
|
1491
|
+
test("says the counts are a floor rather than a total", async () => {
|
|
1492
|
+
respondWith(
|
|
1493
|
+
pageJson({ users: ALL_THREE_USERS, isTruncated: true, totalCount: 3 }),
|
|
1494
|
+
);
|
|
1495
|
+
|
|
1496
|
+
await renderPolicyCard();
|
|
1497
|
+
|
|
1498
|
+
expect(
|
|
1499
|
+
await screen.findByText(/hit its own read limits/),
|
|
1500
|
+
).toBeInTheDocument();
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
test("a complete answer carries no such caveat", async () => {
|
|
1504
|
+
respondWith(pageJson({ users: ALL_THREE_USERS }));
|
|
1505
|
+
|
|
1506
|
+
await renderPolicyCard();
|
|
1507
|
+
|
|
1508
|
+
await screen.findByText("Zed Unreachable");
|
|
1509
|
+
|
|
1510
|
+
expect(screen.queryByText(/hit its own read limits/)).toBeNull();
|
|
1511
|
+
expect(
|
|
1512
|
+
screen.queryByText(/more than this card reads at once/),
|
|
1513
|
+
).toBeNull();
|
|
1514
|
+
});
|
|
1515
|
+
});
|
|
1516
|
+
|
|
1517
|
+
/*
|
|
1518
|
+
* The defect this block exists for: the consequence sentence used to hardcode
|
|
1519
|
+
* "Those pages still reach them ... so nothing is dropped" for EVERY amber
|
|
1520
|
+
* responder. In a project with disableOnCallNotificationFallback set, those
|
|
1521
|
+
* pages are not delivered at all - so the one surface built to stop a silent
|
|
1522
|
+
* unreachable responder was itself silently reassuring the admin. Both states
|
|
1523
|
+
* are asserted, because a copy fix that only ever runs in one of them is not a
|
|
1524
|
+
* fix.
|
|
1525
|
+
*/
|
|
1526
|
+
describe("when the project's fallback is switched off", () => {
|
|
1527
|
+
test("the amber row says the pages are dropped", async () => {
|
|
1528
|
+
respondWith(summaryJson([PARTIAL_USER], false));
|
|
1529
|
+
|
|
1530
|
+
await renderPolicyCard();
|
|
1531
|
+
|
|
1532
|
+
await screen.findByText("Jane Partial");
|
|
1533
|
+
|
|
1534
|
+
const row: HTMLElement = cardRowFor("Jane Partial");
|
|
1535
|
+
|
|
1536
|
+
expect(row.textContent).toContain("fallback switched off");
|
|
1537
|
+
expect(row.textContent).toContain("dropped");
|
|
1538
|
+
expect(row.textContent).not.toContain("so nothing is dropped");
|
|
1539
|
+
expect(row.textContent).not.toContain("still reach them");
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
test("the amber row promises delivery again when it is switched on", async () => {
|
|
1543
|
+
respondWith(summaryJson([PARTIAL_USER], true));
|
|
1544
|
+
|
|
1545
|
+
await renderPolicyCard();
|
|
1546
|
+
|
|
1547
|
+
await screen.findByText("Jane Partial");
|
|
1548
|
+
|
|
1549
|
+
const row: HTMLElement = cardRowFor("Jane Partial");
|
|
1550
|
+
|
|
1551
|
+
expect(row.textContent).toContain("so nothing is dropped");
|
|
1552
|
+
expect(row.textContent).not.toContain("fallback switched off");
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
/*
|
|
1556
|
+
* Said once, above the rows, because it reframes all of them at once. A
|
|
1557
|
+
* reader who knows the product assumes the fallback is on - it is on by
|
|
1558
|
+
* default and on nearly everywhere - and would otherwise read each amber row
|
|
1559
|
+
* as "late" rather than "lost".
|
|
1560
|
+
*/
|
|
1561
|
+
test("the card states the project-wide fact once, at the top", async () => {
|
|
1562
|
+
respondWith(summaryJson([PARTIAL_USER], false));
|
|
1563
|
+
|
|
1564
|
+
await renderPolicyCard();
|
|
1565
|
+
|
|
1566
|
+
expect(
|
|
1567
|
+
await screen.findByText(/it is no page at all/),
|
|
1568
|
+
).toBeInTheDocument();
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
test("an ordinary project is not given that banner", async () => {
|
|
1572
|
+
respondWith(summaryJson([PARTIAL_USER], true));
|
|
1573
|
+
|
|
1574
|
+
await renderPolicyCard();
|
|
1575
|
+
|
|
1576
|
+
await screen.findByText("Jane Partial");
|
|
1577
|
+
|
|
1578
|
+
expect(screen.queryByText(/it is no page at all/)).toBeNull();
|
|
1579
|
+
});
|
|
1580
|
+
|
|
1581
|
+
test("the mail draft to the responder says it too", async () => {
|
|
1582
|
+
respondWith(summaryJson([PARTIAL_USER], false));
|
|
1583
|
+
|
|
1584
|
+
await renderPolicyCard();
|
|
1585
|
+
|
|
1586
|
+
await screen.findByText("Jane Partial");
|
|
1587
|
+
|
|
1588
|
+
const href: string = decodeURIComponent(
|
|
1589
|
+
cardRowFor("Jane Partial")
|
|
1590
|
+
.querySelector('a[href^="mailto:"]')
|
|
1591
|
+
?.getAttribute("href") || "",
|
|
1592
|
+
);
|
|
1593
|
+
|
|
1594
|
+
expect(href).toContain("no notification rule on your account");
|
|
1595
|
+
expect(href).toContain("fallback switched off");
|
|
1596
|
+
expect(href).not.toContain("still reach you");
|
|
1597
|
+
});
|
|
1598
|
+
});
|
|
1599
|
+
|
|
1600
|
+
/*
|
|
1601
|
+
* The other way the old copy lied. A responder can hold a verified phone number
|
|
1602
|
+
* and still be unreachable because the project switched SMS and Call off, and
|
|
1603
|
+
* "has no verified notification method" sends the admin to chase a verification
|
|
1604
|
+
* that already happened instead of to the project setting that is actually
|
|
1605
|
+
* dropping the page.
|
|
1606
|
+
*/
|
|
1607
|
+
describe("a responder whose only channels are switched off", () => {
|
|
1608
|
+
test("is not described as having no verified method", async () => {
|
|
1609
|
+
respondWith(summaryJson([CHANNELS_OFF_USER]));
|
|
1610
|
+
|
|
1611
|
+
await renderPolicyCard();
|
|
1612
|
+
|
|
1613
|
+
await screen.findByText("Sam Switchedoff");
|
|
1614
|
+
|
|
1615
|
+
const row: HTMLElement = cardRowFor("Sam Switchedoff");
|
|
1616
|
+
|
|
1617
|
+
expect(row.textContent).toContain("channels switched off");
|
|
1618
|
+
expect(row.textContent).toContain("is dropped");
|
|
1619
|
+
expect(row.textContent).not.toContain("has no verified notification");
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
test("names the channels they do have, so the fix is findable", async () => {
|
|
1623
|
+
respondWith(summaryJson([CHANNELS_OFF_USER]));
|
|
1624
|
+
|
|
1625
|
+
await renderPolicyCard();
|
|
1626
|
+
|
|
1627
|
+
await screen.findByText("Sam Switchedoff");
|
|
1628
|
+
|
|
1629
|
+
expect(cardRowFor("Sam Switchedoff").textContent).toContain("SMS");
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
test("the mail draft does not ask them to verify what they verified", async () => {
|
|
1633
|
+
respondWith(summaryJson([CHANNELS_OFF_USER]));
|
|
1634
|
+
|
|
1635
|
+
await renderPolicyCard();
|
|
1636
|
+
|
|
1637
|
+
await screen.findByText("Sam Switchedoff");
|
|
1638
|
+
|
|
1639
|
+
const href: string = decodeURIComponent(
|
|
1640
|
+
cardRowFor("Sam Switchedoff")
|
|
1641
|
+
.querySelector('a[href^="mailto:"]')
|
|
1642
|
+
?.getAttribute("href") || "",
|
|
1643
|
+
);
|
|
1644
|
+
|
|
1645
|
+
expect(href).toContain("switched off");
|
|
1646
|
+
expect(href).not.toContain("no verified notification method");
|
|
1647
|
+
});
|
|
1648
|
+
});
|
|
1649
|
+
|
|
1650
|
+
describe("every warning ships with its fix", () => {
|
|
1651
|
+
/*
|
|
1652
|
+
* Admins cannot edit somebody else's notification setup - that boundary is
|
|
1653
|
+
* deliberate, since attaching a phone number to another account is a
|
|
1654
|
+
* paging-hijack vector - so for anyone but the signed-in user the action is
|
|
1655
|
+
* a prefilled nudge rather than an edit.
|
|
1656
|
+
*/
|
|
1657
|
+
test("another person's row offers a prefilled mail draft", async () => {
|
|
1658
|
+
respondWith(summaryJson([PARTIAL_USER]));
|
|
1659
|
+
|
|
1660
|
+
await renderPolicyCard();
|
|
1661
|
+
|
|
1662
|
+
await screen.findByText("Jane Partial");
|
|
1663
|
+
|
|
1664
|
+
const row: HTMLElement = cardRowFor("Jane Partial");
|
|
1665
|
+
|
|
1666
|
+
expect(row.querySelector('a[href^="mailto:"]')).not.toBeNull();
|
|
1667
|
+
expect(row.textContent).toContain("Email Jane the fix");
|
|
1668
|
+
});
|
|
1669
|
+
|
|
1670
|
+
test("the draft is addressed in the second person", async () => {
|
|
1671
|
+
respondWith(summaryJson([PARTIAL_USER]));
|
|
1672
|
+
|
|
1673
|
+
await renderPolicyCard();
|
|
1674
|
+
|
|
1675
|
+
await screen.findByText("Jane Partial");
|
|
1676
|
+
|
|
1677
|
+
const href: string =
|
|
1678
|
+
cardRowFor("Jane Partial")
|
|
1679
|
+
.querySelector('a[href^="mailto:"]')
|
|
1680
|
+
?.getAttribute("href") || "";
|
|
1681
|
+
|
|
1682
|
+
expect(decodeURIComponent(href)).toContain("Hi Jane,");
|
|
1683
|
+
expect(decodeURIComponent(href)).toContain(
|
|
1684
|
+
"no notification rule on your account",
|
|
1685
|
+
);
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
test("an unreachable responder is pointed at notification methods", async () => {
|
|
1689
|
+
respondWith(summaryJson([UNREACHABLE_USER]));
|
|
1690
|
+
|
|
1691
|
+
await renderPolicyCard();
|
|
1692
|
+
|
|
1693
|
+
await screen.findByText("Zed Unreachable");
|
|
1694
|
+
|
|
1695
|
+
const href: string =
|
|
1696
|
+
cardRowFor("Zed Unreachable")
|
|
1697
|
+
.querySelector('a[href^="mailto:"]')
|
|
1698
|
+
?.getAttribute("href") || "";
|
|
1699
|
+
|
|
1700
|
+
expect(decodeURIComponent(href)).toContain(
|
|
1701
|
+
"your account has no verified notification method",
|
|
1702
|
+
);
|
|
1703
|
+
});
|
|
1704
|
+
|
|
1705
|
+
/*
|
|
1706
|
+
* The signed-in user is the one person whose setup they can actually change
|
|
1707
|
+
* from here, so their row gets a real button instead of a nudge.
|
|
1708
|
+
*/
|
|
1709
|
+
test("the signed-in user's own row gets an actionable button", async () => {
|
|
1710
|
+
localStorage.setItem("user_id", SIGNED_IN_USER_ID);
|
|
1711
|
+
|
|
1712
|
+
respondWith(
|
|
1713
|
+
summaryJson([{ ...PARTIAL_USER, userId: SIGNED_IN_USER_ID }]),
|
|
1714
|
+
);
|
|
1715
|
+
|
|
1716
|
+
await renderPolicyCard();
|
|
1717
|
+
|
|
1718
|
+
await screen.findByText("Jane Partial");
|
|
1719
|
+
|
|
1720
|
+
expect(
|
|
1721
|
+
screen.getByRole("button", { name: "Add the missing rules" }),
|
|
1722
|
+
).toBeInTheDocument();
|
|
1723
|
+
expect(
|
|
1724
|
+
cardRowFor("Jane Partial").querySelector('a[href^="mailto:"]'),
|
|
1725
|
+
).toBeNull();
|
|
1726
|
+
});
|
|
1727
|
+
|
|
1728
|
+
test("the signed-in user with no method is sent to add one", async () => {
|
|
1729
|
+
localStorage.setItem("user_id", SIGNED_IN_USER_ID);
|
|
1730
|
+
|
|
1731
|
+
respondWith(
|
|
1732
|
+
summaryJson([{ ...UNREACHABLE_USER, userId: SIGNED_IN_USER_ID }]),
|
|
1733
|
+
);
|
|
1734
|
+
|
|
1735
|
+
await renderPolicyCard();
|
|
1736
|
+
|
|
1737
|
+
await screen.findByText("Zed Unreachable");
|
|
1738
|
+
|
|
1739
|
+
expect(
|
|
1740
|
+
screen.getByRole("button", { name: "Add a notification method" }),
|
|
1741
|
+
).toBeInTheDocument();
|
|
1742
|
+
});
|
|
1743
|
+
});
|
|
1744
|
+
});
|
|
1745
|
+
|
|
1746
|
+
describe("On-call readiness page", () => {
|
|
1747
|
+
describe("empty and error states", () => {
|
|
1748
|
+
test("an empty project says so rather than drawing an empty table", async () => {
|
|
1749
|
+
respondWith(summaryJson([]));
|
|
1750
|
+
|
|
1751
|
+
await renderProjectPage();
|
|
1752
|
+
|
|
1753
|
+
expect(
|
|
1754
|
+
await screen.findByText(
|
|
1755
|
+
"No responders are attached to any on-call policy in this project yet.",
|
|
1756
|
+
),
|
|
1757
|
+
).toBeInTheDocument();
|
|
1758
|
+
expect(screen.queryByRole("table")).toBeNull();
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
test("a failed request renders the failure and a retry", async () => {
|
|
1762
|
+
respondWithError();
|
|
1763
|
+
|
|
1764
|
+
await renderProjectPage();
|
|
1765
|
+
|
|
1766
|
+
expect(
|
|
1767
|
+
await screen.findByText("Could not load readiness"),
|
|
1768
|
+
).toBeInTheDocument();
|
|
1769
|
+
expect(screen.getByRole("refresh-button")).toBeInTheDocument();
|
|
1770
|
+
});
|
|
1771
|
+
|
|
1772
|
+
test("the card chrome survives a failed request", async () => {
|
|
1773
|
+
respondWithError();
|
|
1774
|
+
|
|
1775
|
+
await renderProjectPage();
|
|
1776
|
+
|
|
1777
|
+
await screen.findByText("Could not load readiness");
|
|
1778
|
+
|
|
1779
|
+
expect(screen.getByText("On-call readiness")).toBeInTheDocument();
|
|
1780
|
+
});
|
|
1781
|
+
});
|
|
1782
|
+
|
|
1783
|
+
describe("the three states render distinctly", () => {
|
|
1784
|
+
test("every responder gets the status chip its state earns", async () => {
|
|
1785
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
1786
|
+
|
|
1787
|
+
await renderProjectPage();
|
|
1788
|
+
|
|
1789
|
+
await screen.findByText("Zed Unreachable");
|
|
1790
|
+
|
|
1791
|
+
expect(
|
|
1792
|
+
within(tableRowFor("Zed Unreachable")).getByText("Unreachable"),
|
|
1793
|
+
).toBeInTheDocument();
|
|
1794
|
+
expect(
|
|
1795
|
+
within(tableRowFor("Jane Partial")).getByText(PARTIAL_GAP_LABEL),
|
|
1796
|
+
).toBeInTheDocument();
|
|
1797
|
+
expect(
|
|
1798
|
+
within(tableRowFor("Ada Ready")).getByText("Ready"),
|
|
1799
|
+
).toBeInTheDocument();
|
|
1800
|
+
});
|
|
1801
|
+
|
|
1802
|
+
test("the chips carry three different colours", async () => {
|
|
1803
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
1804
|
+
|
|
1805
|
+
const container: HTMLElement = await renderProjectPage();
|
|
1806
|
+
|
|
1807
|
+
await screen.findByText("Zed Unreachable");
|
|
1808
|
+
|
|
1809
|
+
expect(container.querySelector(".bg-red-50.text-red-700")).not.toBeNull();
|
|
1810
|
+
expect(
|
|
1811
|
+
container.querySelector(".bg-amber-50.text-amber-700"),
|
|
1812
|
+
).not.toBeNull();
|
|
1813
|
+
expect(
|
|
1814
|
+
container.querySelector(".bg-emerald-50.text-emerald-700"),
|
|
1815
|
+
).not.toBeNull();
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
/*
|
|
1819
|
+
* Nobody opens this page to admire the responders who are fine. Unreachable
|
|
1820
|
+
* first, then the most incomplete, is the whole editorial position.
|
|
1821
|
+
*/
|
|
1822
|
+
test("unreachable sorts above needs-setup, which sorts above ready", async () => {
|
|
1823
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
1824
|
+
|
|
1825
|
+
const container: HTMLElement = await renderProjectPage();
|
|
1826
|
+
|
|
1827
|
+
await screen.findByText("Zed Unreachable");
|
|
1828
|
+
|
|
1829
|
+
const rows: Array<string> = Array.from(
|
|
1830
|
+
container.querySelectorAll("tbody tr"),
|
|
1831
|
+
).map((row: Element): string => {
|
|
1832
|
+
return row.textContent || "";
|
|
1833
|
+
});
|
|
1834
|
+
|
|
1835
|
+
expect(rows[0]).toContain("Zed Unreachable");
|
|
1836
|
+
expect(rows[1]).toContain("Jane Partial");
|
|
1837
|
+
expect(rows[2]).toContain("Ada Ready");
|
|
1838
|
+
});
|
|
1839
|
+
|
|
1840
|
+
/*
|
|
1841
|
+
* Scoped to the tile grid because the same four words are also the filter
|
|
1842
|
+
* button labels. A document-wide query would pass on the filter alone and
|
|
1843
|
+
* never notice a tile that stopped counting.
|
|
1844
|
+
*/
|
|
1845
|
+
test("the summary tiles count each state separately", async () => {
|
|
1846
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
1847
|
+
|
|
1848
|
+
const container: HTMLElement = await renderProjectPage();
|
|
1849
|
+
|
|
1850
|
+
await screen.findByText("Zed Unreachable");
|
|
1851
|
+
|
|
1852
|
+
const tiles: HTMLElement | null = container.querySelector(
|
|
1853
|
+
"div.mb-6.grid",
|
|
1854
|
+
) as HTMLElement | null;
|
|
1855
|
+
|
|
1856
|
+
if (!tiles) {
|
|
1857
|
+
throw new Error("no summary tile grid");
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
expect(within(tiles).getByText("Responders")).toBeInTheDocument();
|
|
1861
|
+
expect(within(tiles).getByText("Ready")).toBeInTheDocument();
|
|
1862
|
+
expect(within(tiles).getByText("Needs setup")).toBeInTheDocument();
|
|
1863
|
+
expect(within(tiles).getByText("Unreachable")).toBeInTheDocument();
|
|
1864
|
+
|
|
1865
|
+
// One of each, out of three responders.
|
|
1866
|
+
expect(within(tiles).getByText("3")).toBeInTheDocument();
|
|
1867
|
+
expect(within(tiles).getAllByText("1")).toHaveLength(3);
|
|
1868
|
+
});
|
|
1869
|
+
});
|
|
1870
|
+
|
|
1871
|
+
describe("the coverage matrix", () => {
|
|
1872
|
+
/*
|
|
1873
|
+
* The grid is the centrepiece: severities down, rule types across, so a gap
|
|
1874
|
+
* is a literal hole you can see. Prose ("Missing notification rules for
|
|
1875
|
+
* incident severities: Sev1, Sev2") made a reader rebuild that grid in their
|
|
1876
|
+
* head, once per responder.
|
|
1877
|
+
*/
|
|
1878
|
+
test("covered, gap and muted cells are three different marks", async () => {
|
|
1879
|
+
respondWith(summaryJson([PARTIAL_USER]));
|
|
1880
|
+
|
|
1881
|
+
await renderProjectPage();
|
|
1882
|
+
|
|
1883
|
+
await screen.findByText("Jane Partial");
|
|
1884
|
+
|
|
1885
|
+
openCoverage("Jane Partial");
|
|
1886
|
+
|
|
1887
|
+
expect(
|
|
1888
|
+
screen.getAllByRole("img", {
|
|
1889
|
+
name: "Covered - a notification rule exists",
|
|
1890
|
+
}).length,
|
|
1891
|
+
).toBeGreaterThan(0);
|
|
1892
|
+
expect(
|
|
1893
|
+
screen.getAllByRole("img", {
|
|
1894
|
+
name: "No rule - pages here fall back to a verified method",
|
|
1895
|
+
}).length,
|
|
1896
|
+
).toBeGreaterThan(0);
|
|
1897
|
+
expect(
|
|
1898
|
+
screen.getAllByRole("img", {
|
|
1899
|
+
name: "Muted - notifications intentionally turned off",
|
|
1900
|
+
}).length,
|
|
1901
|
+
).toBeGreaterThan(0);
|
|
1902
|
+
});
|
|
1903
|
+
|
|
1904
|
+
/*
|
|
1905
|
+
* A deliberate mute is not a gap. Counting it as one nags people for having
|
|
1906
|
+
* configured the product correctly, which is the fastest way to make the
|
|
1907
|
+
* whole surface ignorable. Nine cells, one muted and three genuinely
|
|
1908
|
+
* missing: if the mute were counted the chip would read "4 gaps" and the
|
|
1909
|
+
* coverage column "5 of 9".
|
|
1910
|
+
*/
|
|
1911
|
+
test("a muted cell is not counted as a gap", async () => {
|
|
1912
|
+
respondWith(summaryJson([PARTIAL_USER]));
|
|
1913
|
+
|
|
1914
|
+
await renderProjectPage();
|
|
1915
|
+
|
|
1916
|
+
await screen.findByText("Jane Partial");
|
|
1917
|
+
|
|
1918
|
+
expect(
|
|
1919
|
+
within(tableRowFor("Jane Partial")).getByText(PARTIAL_GAP_LABEL),
|
|
1920
|
+
).toBeInTheDocument();
|
|
1921
|
+
expect(
|
|
1922
|
+
within(tableRowFor("Jane Partial")).getByText("6 of 9"),
|
|
1923
|
+
).toBeInTheDocument();
|
|
1924
|
+
});
|
|
1925
|
+
|
|
1926
|
+
/*
|
|
1927
|
+
* A pair the service did not report is not the same claim as "no rule", so
|
|
1928
|
+
* it renders neutral rather than as an accusation the reader wastes time
|
|
1929
|
+
* chasing.
|
|
1930
|
+
*/
|
|
1931
|
+
test("an unreported pair renders as not-reported, not as a gap", async () => {
|
|
1932
|
+
respondWith(summaryJson([PARTIAL_USER]));
|
|
1933
|
+
|
|
1934
|
+
const container: HTMLElement = await renderProjectPage();
|
|
1935
|
+
|
|
1936
|
+
await screen.findByText("Jane Partial");
|
|
1937
|
+
|
|
1938
|
+
openCoverage("Jane Partial");
|
|
1939
|
+
|
|
1940
|
+
expect(container.querySelector('[title="Not reported"]')).not.toBeNull();
|
|
1941
|
+
});
|
|
1942
|
+
|
|
1943
|
+
test("the columns are the four severity rule types, in a fixed order", async () => {
|
|
1944
|
+
respondWith(summaryJson([PARTIAL_USER]));
|
|
1945
|
+
|
|
1946
|
+
await renderProjectPage();
|
|
1947
|
+
|
|
1948
|
+
await screen.findByText("Jane Partial");
|
|
1949
|
+
|
|
1950
|
+
openCoverage("Jane Partial");
|
|
1951
|
+
|
|
1952
|
+
const headers: Array<string> = screen
|
|
1953
|
+
.getAllByRole("columnheader")
|
|
1954
|
+
.map((header: HTMLElement): string => {
|
|
1955
|
+
return header.textContent || "";
|
|
1956
|
+
});
|
|
1957
|
+
|
|
1958
|
+
expect(headers).toContain("Severity");
|
|
1959
|
+
expect(headers).toContain("Incident");
|
|
1960
|
+
expect(headers).toContain("Alert");
|
|
1961
|
+
expect(headers).toContain("Incident episode");
|
|
1962
|
+
expect(headers).toContain("Alert episode");
|
|
1963
|
+
});
|
|
1964
|
+
|
|
1965
|
+
/*
|
|
1966
|
+
* The two lifecycle rule types carry no severity, so they cannot be matrix
|
|
1967
|
+
* rows. Dropping them instead would silently hide a real gap - the very
|
|
1968
|
+
* mistake the compliance report made by treating rule types as
|
|
1969
|
+
* interchangeable.
|
|
1970
|
+
*/
|
|
1971
|
+
test("the severity-less rule types get their own section", async () => {
|
|
1972
|
+
respondWith(summaryJson([PARTIAL_USER]));
|
|
1973
|
+
|
|
1974
|
+
await renderProjectPage();
|
|
1975
|
+
|
|
1976
|
+
await screen.findByText("Jane Partial");
|
|
1977
|
+
|
|
1978
|
+
openCoverage("Jane Partial");
|
|
1979
|
+
|
|
1980
|
+
expect(screen.getByText("Shift changes")).toBeInTheDocument();
|
|
1981
|
+
expect(screen.getByText("Goes on call")).toBeInTheDocument();
|
|
1982
|
+
expect(screen.getByText("Goes off call")).toBeInTheDocument();
|
|
1983
|
+
});
|
|
1984
|
+
|
|
1985
|
+
test("both severities appear as rows", async () => {
|
|
1986
|
+
respondWith(summaryJson([PARTIAL_USER]));
|
|
1987
|
+
|
|
1988
|
+
await renderProjectPage();
|
|
1989
|
+
|
|
1990
|
+
await screen.findByText("Jane Partial");
|
|
1991
|
+
|
|
1992
|
+
openCoverage("Jane Partial");
|
|
1993
|
+
|
|
1994
|
+
expect(screen.getAllByText("Sev1").length).toBeGreaterThan(0);
|
|
1995
|
+
expect(screen.getAllByText("Sev2").length).toBeGreaterThan(0);
|
|
1996
|
+
});
|
|
1997
|
+
});
|
|
1998
|
+
|
|
1999
|
+
/*
|
|
2000
|
+
* The matrix has to have a severity KIND dimension, and these are the tests
|
|
2001
|
+
* that say so.
|
|
2002
|
+
*
|
|
2003
|
+
* The first cut keyed rows on severity id alone while the columns were all
|
|
2004
|
+
* four severity-scoped rule types. Incident severity ids and alert severity
|
|
2005
|
+
* ids are disjoint - they are rows in two different tables - so that grid put
|
|
2006
|
+
* every alert severity under an "Incident" column and every incident severity
|
|
2007
|
+
* under an "Alert" one. Half of every grid was cells that can never exist, and
|
|
2008
|
+
* nothing on screen distinguished one of those from a genuine hole, on the one
|
|
2009
|
+
* surface whose entire job is making a hole visible.
|
|
2010
|
+
*/
|
|
2011
|
+
describe("the coverage matrix is split by severity kind", () => {
|
|
2012
|
+
type MatrixForFunction = (title: string) => HTMLElement;
|
|
2013
|
+
|
|
2014
|
+
const matrixFor: MatrixForFunction = (title: string): HTMLElement => {
|
|
2015
|
+
return screen.getByRole("table", { name: `${title} coverage` });
|
|
2016
|
+
};
|
|
2017
|
+
|
|
2018
|
+
/*
|
|
2019
|
+
* Every drawn cell carries one of the four state titles, and only cells do -
|
|
2020
|
+
* the severity name cell that opens each row does not - so counting titled
|
|
2021
|
+
* marks inside a matrix counts exactly the pairs it claims exist.
|
|
2022
|
+
*/
|
|
2023
|
+
type CountDrawnCellsFunction = (matrix: HTMLElement) => number;
|
|
2024
|
+
|
|
2025
|
+
const countDrawnCells: CountDrawnCellsFunction = (
|
|
2026
|
+
matrix: HTMLElement,
|
|
2027
|
+
): number => {
|
|
2028
|
+
return matrix.querySelectorAll("tbody td span[title]").length;
|
|
2029
|
+
};
|
|
2030
|
+
|
|
2031
|
+
type HeadersOfFunction = (matrix: HTMLElement) => Array<string>;
|
|
2032
|
+
|
|
2033
|
+
const headersOf: HeadersOfFunction = (
|
|
2034
|
+
matrix: HTMLElement,
|
|
2035
|
+
): Array<string> => {
|
|
2036
|
+
return within(matrix)
|
|
2037
|
+
.getAllByRole("columnheader")
|
|
2038
|
+
.map((header: HTMLElement): string => {
|
|
2039
|
+
return header.textContent || "";
|
|
2040
|
+
});
|
|
2041
|
+
};
|
|
2042
|
+
|
|
2043
|
+
type ExpandKaiFunction = () => Promise<void>;
|
|
2044
|
+
|
|
2045
|
+
const expandKai: ExpandKaiFunction = async (): Promise<void> => {
|
|
2046
|
+
respondWith(summaryJson([DISTINCT_KIND_USER]));
|
|
2047
|
+
|
|
2048
|
+
await renderProjectPage();
|
|
2049
|
+
|
|
2050
|
+
await screen.findByText("Kai Distinct");
|
|
2051
|
+
|
|
2052
|
+
openCoverage("Kai Distinct");
|
|
2053
|
+
};
|
|
2054
|
+
|
|
2055
|
+
test("each kind gets its own matrix", async () => {
|
|
2056
|
+
await expandKai();
|
|
2057
|
+
|
|
2058
|
+
expect(matrixFor("Incident severities")).toBeInTheDocument();
|
|
2059
|
+
expect(matrixFor("Alert severities")).toBeInTheDocument();
|
|
2060
|
+
});
|
|
2061
|
+
|
|
2062
|
+
test("the incident matrix carries only incident columns", async () => {
|
|
2063
|
+
await expandKai();
|
|
2064
|
+
|
|
2065
|
+
expect(headersOf(matrixFor("Incident severities"))).toEqual([
|
|
2066
|
+
"Severity",
|
|
2067
|
+
"Incident",
|
|
2068
|
+
"Incident episode",
|
|
2069
|
+
]);
|
|
2070
|
+
});
|
|
2071
|
+
|
|
2072
|
+
test("the alert matrix carries only alert columns", async () => {
|
|
2073
|
+
await expandKai();
|
|
2074
|
+
|
|
2075
|
+
expect(headersOf(matrixFor("Alert severities"))).toEqual([
|
|
2076
|
+
"Severity",
|
|
2077
|
+
"Alert",
|
|
2078
|
+
"Alert episode",
|
|
2079
|
+
]);
|
|
2080
|
+
});
|
|
2081
|
+
|
|
2082
|
+
/*
|
|
2083
|
+
* The defect itself: an alert severity must never appear on the incident
|
|
2084
|
+
* axis, because there is no such thing as that severity on an incident.
|
|
2085
|
+
*/
|
|
2086
|
+
test("neither kind's severities leak into the other kind's matrix", async () => {
|
|
2087
|
+
await expandKai();
|
|
2088
|
+
|
|
2089
|
+
const incidentMatrix: HTMLElement = matrixFor("Incident severities");
|
|
2090
|
+
const alertMatrix: HTMLElement = matrixFor("Alert severities");
|
|
2091
|
+
|
|
2092
|
+
expect(
|
|
2093
|
+
within(incidentMatrix).getByText(INCIDENT_ONLY_SEV_NAME),
|
|
2094
|
+
).toBeInTheDocument();
|
|
2095
|
+
expect(
|
|
2096
|
+
within(incidentMatrix).queryByText(ALERT_ONLY_SEV_NAME),
|
|
2097
|
+
).toBeNull();
|
|
2098
|
+
|
|
2099
|
+
expect(
|
|
2100
|
+
within(alertMatrix).getByText(ALERT_ONLY_SEV_NAME),
|
|
2101
|
+
).toBeInTheDocument();
|
|
2102
|
+
expect(
|
|
2103
|
+
within(alertMatrix).queryByText(INCIDENT_ONLY_SEV_NAME),
|
|
2104
|
+
).toBeNull();
|
|
2105
|
+
});
|
|
2106
|
+
|
|
2107
|
+
/*
|
|
2108
|
+
* The counting version of the same claim, and the one that fails loudest if
|
|
2109
|
+
* the kind dimension is ever dropped again: four pairs exist for this
|
|
2110
|
+
* responder, so four cells are drawn. The old single-axis grid drew eight -
|
|
2111
|
+
* two severities across four columns - and the four extras were impossible.
|
|
2112
|
+
*/
|
|
2113
|
+
test("it draws exactly as many cells as there are pairs that can exist", async () => {
|
|
2114
|
+
await expandKai();
|
|
2115
|
+
|
|
2116
|
+
const drawnCells: number =
|
|
2117
|
+
countDrawnCells(matrixFor("Incident severities")) +
|
|
2118
|
+
countDrawnCells(matrixFor("Alert severities"));
|
|
2119
|
+
|
|
2120
|
+
expect(drawnCells).toBe(DISTINCT_KIND_COVERAGE.length);
|
|
2121
|
+
});
|
|
2122
|
+
|
|
2123
|
+
/*
|
|
2124
|
+
* Kind is a property of the RULE TYPE a cell arrived under, never of the id,
|
|
2125
|
+
* so a project that happens to reuse an id across the two severity tables
|
|
2126
|
+
* still lands each cell in the matrix it belongs to rather than merging the
|
|
2127
|
+
* two rows back into one.
|
|
2128
|
+
*/
|
|
2129
|
+
test("a shared severity id still lands in both matrices, once each", async () => {
|
|
2130
|
+
respondWith(summaryJson([PARTIAL_USER]));
|
|
2131
|
+
|
|
2132
|
+
await renderProjectPage();
|
|
2133
|
+
|
|
2134
|
+
await screen.findByText("Jane Partial");
|
|
2135
|
+
|
|
2136
|
+
openCoverage("Jane Partial");
|
|
2137
|
+
|
|
2138
|
+
expect(
|
|
2139
|
+
within(matrixFor("Incident severities")).getAllByText("Sev1"),
|
|
2140
|
+
).toHaveLength(1);
|
|
2141
|
+
expect(
|
|
2142
|
+
within(matrixFor("Alert severities")).getAllByText("Sev1"),
|
|
2143
|
+
).toHaveLength(1);
|
|
2144
|
+
});
|
|
2145
|
+
});
|
|
2146
|
+
|
|
2147
|
+
/*
|
|
2148
|
+
* The setup-reminder control, now that it is real.
|
|
2149
|
+
*
|
|
2150
|
+
* Its history is the reason these assertions are shaped the way they are. The
|
|
2151
|
+
* first cut wired an enabled button to a function that unconditionally threw:
|
|
2152
|
+
* an admin ticked responders, confirmed a modal naming them, and got an error.
|
|
2153
|
+
* The second was honestly disabled, because a silent no-op would have been
|
|
2154
|
+
* worse still - they would have left believing a reminder reached somebody who
|
|
2155
|
+
* still cannot be paged.
|
|
2156
|
+
*
|
|
2157
|
+
* So the standard the enabled version has to meet is not "does it POST". It is
|
|
2158
|
+
* that the page never says a reminder went out unless the server said that
|
|
2159
|
+
* reminder went out, per user:
|
|
2160
|
+
*
|
|
2161
|
+
* - a throttled skip must not render as a send (a throttle is not a send);
|
|
2162
|
+
* - a failure must not be swallowed by the successes around it;
|
|
2163
|
+
* - a failed REQUEST must say plainly that nothing was sent, and must not be
|
|
2164
|
+
* confusable with a partial success, because "try again" is right for one
|
|
2165
|
+
* and wrong for the other;
|
|
2166
|
+
* - and the headline count must be derived from the same list it is printed
|
|
2167
|
+
* above, so the two can never disagree on screen.
|
|
2168
|
+
*/
|
|
2169
|
+
describe("the setup-reminder control - selecting responders", () => {
|
|
2170
|
+
test("every responder row carries a named checkbox", async () => {
|
|
2171
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
2172
|
+
|
|
2173
|
+
await renderProjectPage();
|
|
2174
|
+
|
|
2175
|
+
await screen.findByText("Zed Unreachable");
|
|
2176
|
+
|
|
2177
|
+
/*
|
|
2178
|
+
* Named, not merely present. The shared table draws an unlabelled box in
|
|
2179
|
+
* every row unless it is told what a row IS, and a column of boxes that
|
|
2180
|
+
* all announce themselves as "checkbox" is one a screen reader user
|
|
2181
|
+
* cannot tell apart - and one this file could not address either.
|
|
2182
|
+
*/
|
|
2183
|
+
expect(
|
|
2184
|
+
screen.getByLabelText("Select Zed Unreachable"),
|
|
2185
|
+
).toBeInTheDocument();
|
|
2186
|
+
expect(screen.getByLabelText("Select Jane Partial")).toBeInTheDocument();
|
|
2187
|
+
});
|
|
2188
|
+
|
|
2189
|
+
/*
|
|
2190
|
+
* A Ready responder has nothing to be reminded of, and the server agrees -
|
|
2191
|
+
* it answers SkippedNothingMissing rather than mailing them a claim they
|
|
2192
|
+
* could disprove in ten seconds. Locking the box says the same thing before
|
|
2193
|
+
* the click instead of explaining it after.
|
|
2194
|
+
*/
|
|
2195
|
+
test("a ready responder cannot be selected, and the box says why", async () => {
|
|
2196
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
2197
|
+
|
|
2198
|
+
await renderProjectPage();
|
|
2199
|
+
|
|
2200
|
+
await screen.findByText("Ada Ready");
|
|
2201
|
+
|
|
2202
|
+
const box: HTMLElement = screen.getByLabelText("Select Ada Ready");
|
|
2203
|
+
|
|
2204
|
+
expect(box).toBeDisabled();
|
|
2205
|
+
expect(box).toHaveAttribute(
|
|
2206
|
+
"title",
|
|
2207
|
+
"Already ready - there is nothing to remind them about",
|
|
2208
|
+
);
|
|
2209
|
+
});
|
|
2210
|
+
|
|
2211
|
+
test("the bulk bar appears only once something is selected, and counts it", async () => {
|
|
2212
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
2213
|
+
|
|
2214
|
+
await renderProjectPage();
|
|
2215
|
+
|
|
2216
|
+
await screen.findByText("Zed Unreachable");
|
|
2217
|
+
|
|
2218
|
+
expect(screen.queryByText("Bulk Actions")).toBeNull();
|
|
2219
|
+
|
|
2220
|
+
selectResponder("Zed Unreachable");
|
|
2221
|
+
|
|
2222
|
+
expect(screen.getByText("1 Responders Selected")).toBeInTheDocument();
|
|
2223
|
+
expect(screen.getByText("Bulk Actions")).toBeInTheDocument();
|
|
2224
|
+
});
|
|
2225
|
+
|
|
2226
|
+
/*
|
|
2227
|
+
* Ticking a box must not do anything else. The row used to expand on click
|
|
2228
|
+
* and the checkbox cell had to stop propagation by hand; the coverage grid
|
|
2229
|
+
* now opens from its own button, so the two cannot collide at all - and this
|
|
2230
|
+
* is the assertion that says so.
|
|
2231
|
+
*/
|
|
2232
|
+
test("ticking a box does not open the coverage grid", async () => {
|
|
2233
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
2234
|
+
|
|
2235
|
+
await renderProjectPage();
|
|
2236
|
+
|
|
2237
|
+
await screen.findByText("Jane Partial");
|
|
2238
|
+
|
|
2239
|
+
selectResponder("Jane Partial");
|
|
2240
|
+
|
|
2241
|
+
expect(screen.queryByText("Incident severities")).toBeNull();
|
|
2242
|
+
});
|
|
2243
|
+
|
|
2244
|
+
test("the header checkbox takes the remindable rows and leaves the ready one", async () => {
|
|
2245
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
2246
|
+
|
|
2247
|
+
await renderProjectPage();
|
|
2248
|
+
|
|
2249
|
+
await screen.findByText("Ada Ready");
|
|
2250
|
+
|
|
2251
|
+
fireEvent.click(screen.getByLabelText("Select all items"));
|
|
2252
|
+
|
|
2253
|
+
expect(screen.getByText("2 Responders Selected")).toBeInTheDocument();
|
|
2254
|
+
expect(screen.getByLabelText("Select Ada Ready")).not.toBeChecked();
|
|
2255
|
+
});
|
|
2256
|
+
|
|
2257
|
+
/*
|
|
2258
|
+
* "All selected" is judged against the rows that CAN be selected. Counting
|
|
2259
|
+
* the locked Ready row against it would leave the header box permanently
|
|
2260
|
+
* empty on any page holding one, which reads as "your selection was lost"
|
|
2261
|
+
* rather than as "one row here is not yours to pick".
|
|
2262
|
+
*/
|
|
2263
|
+
test("the header checkbox fills even though a locked row stays unticked", async () => {
|
|
2264
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
2265
|
+
|
|
2266
|
+
await renderProjectPage();
|
|
2267
|
+
|
|
2268
|
+
await screen.findByText("Ada Ready");
|
|
2269
|
+
|
|
2270
|
+
fireEvent.click(screen.getByLabelText("Select all items"));
|
|
2271
|
+
|
|
2272
|
+
expect(screen.getByLabelText("Select all items")).toBeChecked();
|
|
2273
|
+
expect(screen.getByLabelText("Select Ada Ready")).not.toBeChecked();
|
|
2274
|
+
});
|
|
2275
|
+
|
|
2276
|
+
test("the selection can be cleared without sending anything", async () => {
|
|
2277
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
2278
|
+
|
|
2279
|
+
await renderProjectPage();
|
|
2280
|
+
|
|
2281
|
+
await screen.findByText("Zed Unreachable");
|
|
2282
|
+
|
|
2283
|
+
selectResponder("Zed Unreachable");
|
|
2284
|
+
fireEvent.click(screen.getByText("Clear Selection"));
|
|
2285
|
+
|
|
2286
|
+
expect(screen.queryByText("Bulk Actions")).toBeNull();
|
|
2287
|
+
expect(postMock).not.toHaveBeenCalled();
|
|
2288
|
+
});
|
|
2289
|
+
|
|
2290
|
+
/*
|
|
2291
|
+
* "Select All Responders" means every row the CURRENT FILTER matches, not
|
|
2292
|
+
* every row in the project - and still never a Ready one. An admin who
|
|
2293
|
+
* filtered to one team and pressed it must not mail the other four teams.
|
|
2294
|
+
*/
|
|
2295
|
+
test("select-all covers the filtered rows and still skips the ready ones", async () => {
|
|
2296
|
+
respondWith(summaryJson([...ALL_THREE_USERS, TWO_TEAM_USER]));
|
|
2297
|
+
|
|
2298
|
+
await renderProjectPage();
|
|
2299
|
+
|
|
2300
|
+
await screen.findByText("Ravi Twoteams");
|
|
2301
|
+
|
|
2302
|
+
selectResponder("Zed Unreachable");
|
|
2303
|
+
fireEvent.click(screen.getByText("Select All Responders"));
|
|
2304
|
+
|
|
2305
|
+
await waitFor((): void => {
|
|
2306
|
+
expect(screen.getByText("3 Responders Selected")).toBeInTheDocument();
|
|
2307
|
+
});
|
|
2308
|
+
});
|
|
2309
|
+
});
|
|
2310
|
+
|
|
2311
|
+
describe("the setup-reminder control - confirming and sending", () => {
|
|
2312
|
+
type SelectAndOpenFunction = () => Promise<void>;
|
|
2313
|
+
|
|
2314
|
+
const selectZedAndOpenModal: SelectAndOpenFunction =
|
|
2315
|
+
async (): Promise<void> => {
|
|
2316
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
2317
|
+
|
|
2318
|
+
await renderProjectPage();
|
|
2319
|
+
|
|
2320
|
+
await screen.findByText("Zed Unreachable");
|
|
2321
|
+
|
|
2322
|
+
selectResponder("Zed Unreachable");
|
|
2323
|
+
runBulkAction("Send setup reminder");
|
|
2324
|
+
};
|
|
2325
|
+
|
|
2326
|
+
/*
|
|
2327
|
+
* The modal names them. Selection survives a filter change AND a page
|
|
2328
|
+
* change, so the rows an admin ticked are not necessarily the rows on
|
|
2329
|
+
* screen when they press the button - and this is the last moment before
|
|
2330
|
+
* real email reaches real people. "Send 6 reminders?" would be asking them
|
|
2331
|
+
* to confirm a number.
|
|
2332
|
+
*/
|
|
2333
|
+
test("the confirmation names every recipient", async () => {
|
|
2334
|
+
await selectZedAndOpenModal();
|
|
2335
|
+
|
|
2336
|
+
const modal: HTMLElement = screen.getByTestId("modal");
|
|
2337
|
+
|
|
2338
|
+
expect(within(modal).getByText(/Zed Unreachable/)).toBeInTheDocument();
|
|
2339
|
+
expect(within(modal).queryByText(/Ada Ready/)).toBeNull();
|
|
2340
|
+
});
|
|
2341
|
+
|
|
2342
|
+
test("the confirmation counts the recipients in its title", async () => {
|
|
2343
|
+
await selectZedAndOpenModal();
|
|
2344
|
+
|
|
2345
|
+
expect(
|
|
2346
|
+
within(screen.getByTestId("modal")).getByText(
|
|
2347
|
+
/Send a setup reminder to 1 responder\?/,
|
|
2348
|
+
),
|
|
2349
|
+
).toBeInTheDocument();
|
|
2350
|
+
});
|
|
2351
|
+
|
|
2352
|
+
test("the confirmation says the 24h throttle exists before anything is sent", async () => {
|
|
2353
|
+
await selectZedAndOpenModal();
|
|
2354
|
+
|
|
2355
|
+
expect(
|
|
2356
|
+
within(screen.getByTestId("modal")).getByText(/24 hours/),
|
|
2357
|
+
).toBeInTheDocument();
|
|
2358
|
+
});
|
|
2359
|
+
|
|
2360
|
+
test("opening the confirmation sends nothing on its own", async () => {
|
|
2361
|
+
await selectZedAndOpenModal();
|
|
2362
|
+
|
|
2363
|
+
expect(postMock).not.toHaveBeenCalled();
|
|
2364
|
+
});
|
|
2365
|
+
|
|
2366
|
+
test("confirming posts the selected ids to the reminder endpoint", async () => {
|
|
2367
|
+
respondToReminderWith(
|
|
2368
|
+
reminderJson([
|
|
2369
|
+
{
|
|
2370
|
+
userId: UNREACHABLE_USER_ID,
|
|
2371
|
+
outcome: "Sent",
|
|
2372
|
+
message: "Reminder sent to their account email address.",
|
|
2373
|
+
},
|
|
2374
|
+
]),
|
|
2375
|
+
);
|
|
2376
|
+
|
|
2377
|
+
await selectZedAndOpenModal();
|
|
2378
|
+
|
|
2379
|
+
fireEvent.click(
|
|
2380
|
+
screen.getByRole("button", { name: "Send setup reminder" }),
|
|
2381
|
+
);
|
|
2382
|
+
|
|
2383
|
+
await waitFor((): void => {
|
|
2384
|
+
expect(postMock).toHaveBeenCalled();
|
|
2385
|
+
});
|
|
2386
|
+
|
|
2387
|
+
const call: Array<any> = postMock.mock.calls[0] as Array<any>;
|
|
2388
|
+
const options: {
|
|
2389
|
+
url: { toString: () => string };
|
|
2390
|
+
data: { userIds: Array<string> };
|
|
2391
|
+
} = call[0];
|
|
2392
|
+
|
|
2393
|
+
expect(options.url.toString()).toContain(
|
|
2394
|
+
"/on-call-readiness/send-setup-reminder",
|
|
2395
|
+
);
|
|
2396
|
+
expect(options.data.userIds).toEqual([UNREACHABLE_USER_ID]);
|
|
2397
|
+
});
|
|
2398
|
+
});
|
|
2399
|
+
|
|
2400
|
+
describe("the setup-reminder control - reporting what happened", () => {
|
|
2401
|
+
type RunReminderFunction = (
|
|
2402
|
+
results: Array<ReminderOutcomeFixture>,
|
|
2403
|
+
) => Promise<void>;
|
|
2404
|
+
|
|
2405
|
+
/*
|
|
2406
|
+
* Select both responders who have something wrong, send, and let the caller
|
|
2407
|
+
* decide what the server said about them.
|
|
2408
|
+
*/
|
|
2409
|
+
const runReminderForBoth: RunReminderFunction = async (
|
|
2410
|
+
results: Array<ReminderOutcomeFixture>,
|
|
2411
|
+
): Promise<void> => {
|
|
2412
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
2413
|
+
respondToReminderWith(reminderJson(results));
|
|
2414
|
+
|
|
2415
|
+
await renderProjectPage();
|
|
2416
|
+
|
|
2417
|
+
await screen.findByText("Zed Unreachable");
|
|
2418
|
+
|
|
2419
|
+
fireEvent.click(screen.getByLabelText("Select all items"));
|
|
2420
|
+
runBulkAction("Send setup reminder");
|
|
2421
|
+
fireEvent.click(
|
|
2422
|
+
screen.getByRole("button", { name: "Send setup reminder" }),
|
|
2423
|
+
);
|
|
2424
|
+
|
|
2425
|
+
await waitFor((): void => {
|
|
2426
|
+
expect(screen.getByText(/Setup reminders:/)).toBeInTheDocument();
|
|
2427
|
+
});
|
|
2428
|
+
};
|
|
2429
|
+
|
|
2430
|
+
test("an all-sent run says so, and does not list the successes", async () => {
|
|
2431
|
+
await runReminderForBoth([
|
|
2432
|
+
{ userId: UNREACHABLE_USER_ID, outcome: "Sent", message: "Sent." },
|
|
2433
|
+
{ userId: PARTIAL_USER_ID, outcome: "Sent", message: "Sent." },
|
|
2434
|
+
]);
|
|
2435
|
+
|
|
2436
|
+
expect(screen.getByText("2 sent.")).toBeInTheDocument();
|
|
2437
|
+
/*
|
|
2438
|
+
* The names appear in the table either way; what must NOT appear is a
|
|
2439
|
+
* per-user line under the banner for somebody nothing went wrong with.
|
|
2440
|
+
*/
|
|
2441
|
+
expect(screen.queryByText(/Sent\./)).toBeNull();
|
|
2442
|
+
});
|
|
2443
|
+
|
|
2444
|
+
/*
|
|
2445
|
+
* The highest-value assertion in this block. A throttle is not a send, and
|
|
2446
|
+
* the run that produced this is the ordinary one the moment a throttle
|
|
2447
|
+
* exists.
|
|
2448
|
+
*
|
|
2449
|
+
* It is also why this page keeps its own banner rather than reporting
|
|
2450
|
+
* through the shared bulk-action progress modal: that modal has two buckets,
|
|
2451
|
+
* succeeded and FAILED, and a responder skipped because they were reminded
|
|
2452
|
+
* an hour ago has not failed at anything.
|
|
2453
|
+
*/
|
|
2454
|
+
test("a throttled skip is counted as skipped and named, never as sent", async () => {
|
|
2455
|
+
await runReminderForBoth([
|
|
2456
|
+
{
|
|
2457
|
+
userId: UNREACHABLE_USER_ID,
|
|
2458
|
+
outcome: "Sent",
|
|
2459
|
+
message: "Reminder sent to their account email address.",
|
|
2460
|
+
},
|
|
2461
|
+
{
|
|
2462
|
+
userId: PARTIAL_USER_ID,
|
|
2463
|
+
outcome: "SkippedThrottled",
|
|
2464
|
+
message: "Already reminded in the last 24 hours.",
|
|
2465
|
+
},
|
|
2466
|
+
]);
|
|
2467
|
+
|
|
2468
|
+
expect(screen.getByText("1 sent, 1 skipped.")).toBeInTheDocument();
|
|
2469
|
+
expect(
|
|
2470
|
+
screen.getByText(/Already reminded in the last 24 hours\./),
|
|
2471
|
+
).toBeInTheDocument();
|
|
2472
|
+
expect(screen.queryByText("2 sent.")).toBeNull();
|
|
2473
|
+
});
|
|
2474
|
+
|
|
2475
|
+
test("a failure is named with the server's own words", async () => {
|
|
2476
|
+
await runReminderForBoth([
|
|
2477
|
+
{ userId: UNREACHABLE_USER_ID, outcome: "Sent", message: "Sent." },
|
|
2478
|
+
{
|
|
2479
|
+
userId: PARTIAL_USER_ID,
|
|
2480
|
+
outcome: "Failed",
|
|
2481
|
+
message: "This responder has no usable account email address.",
|
|
2482
|
+
},
|
|
2483
|
+
]);
|
|
2484
|
+
|
|
2485
|
+
expect(screen.getByText("1 sent, 1 failed.")).toBeInTheDocument();
|
|
2486
|
+
expect(
|
|
2487
|
+
screen.getByText(/no usable account email address/),
|
|
2488
|
+
).toBeInTheDocument();
|
|
2489
|
+
});
|
|
2490
|
+
|
|
2491
|
+
/*
|
|
2492
|
+
* Tone follows the WORST thing in the report. A green banner with a red line
|
|
2493
|
+
* inside it is read as green, which is how a partial failure gets closed as
|
|
2494
|
+
* a success.
|
|
2495
|
+
*/
|
|
2496
|
+
test("any failure colours the whole banner as a failure", async () => {
|
|
2497
|
+
await runReminderForBoth([
|
|
2498
|
+
{ userId: UNREACHABLE_USER_ID, outcome: "Sent", message: "Sent." },
|
|
2499
|
+
{ userId: PARTIAL_USER_ID, outcome: "Failed", message: "Nope." },
|
|
2500
|
+
]);
|
|
2501
|
+
|
|
2502
|
+
const banner: HTMLElement | null = screen
|
|
2503
|
+
.getByText(/Setup reminders:/)
|
|
2504
|
+
.closest("div.rounded-md") as HTMLElement | null;
|
|
2505
|
+
|
|
2506
|
+
expect(banner?.className).toContain("red");
|
|
2507
|
+
expect(banner?.className).not.toContain("emerald");
|
|
2508
|
+
});
|
|
2509
|
+
|
|
2510
|
+
test("a user the page no longer knows still gets an outcome line", async () => {
|
|
2511
|
+
await runReminderForBoth([
|
|
2512
|
+
{ userId: UNREACHABLE_USER_ID, outcome: "Sent", message: "Sent." },
|
|
2513
|
+
{
|
|
2514
|
+
userId: "99999999-8888-4888-8888-999999999999",
|
|
2515
|
+
outcome: "SkippedNotAMember",
|
|
2516
|
+
message: "Not a member of this project.",
|
|
2517
|
+
},
|
|
2518
|
+
]);
|
|
2519
|
+
|
|
2520
|
+
expect(screen.getByText("1 sent, 1 skipped.")).toBeInTheDocument();
|
|
2521
|
+
expect(
|
|
2522
|
+
screen.getByText(/Not a member of this project\./),
|
|
2523
|
+
).toBeInTheDocument();
|
|
2524
|
+
});
|
|
2525
|
+
|
|
2526
|
+
test("an outcome this build has never heard of is counted as skipped, not sent", async () => {
|
|
2527
|
+
/*
|
|
2528
|
+
* Erring towards "skipped" is the safe direction: understating the good
|
|
2529
|
+
* news costs nothing, while counting an unknown outcome as a send is the
|
|
2530
|
+
* page claiming a reminder went out that may not have.
|
|
2531
|
+
*/
|
|
2532
|
+
await runReminderForBoth([
|
|
2533
|
+
{ userId: UNREACHABLE_USER_ID, outcome: "Sent", message: "Sent." },
|
|
2534
|
+
{
|
|
2535
|
+
userId: PARTIAL_USER_ID,
|
|
2536
|
+
outcome: "SomethingNewFromTheFuture",
|
|
2537
|
+
message: "Unknown outcome.",
|
|
2538
|
+
},
|
|
2539
|
+
]);
|
|
2540
|
+
|
|
2541
|
+
expect(screen.getByText("1 sent, 1 skipped.")).toBeInTheDocument();
|
|
2542
|
+
});
|
|
2543
|
+
|
|
2544
|
+
test("the selection is cleared once the run completes", async () => {
|
|
2545
|
+
await runReminderForBoth([
|
|
2546
|
+
{ userId: UNREACHABLE_USER_ID, outcome: "Sent", message: "Sent." },
|
|
2547
|
+
{ userId: PARTIAL_USER_ID, outcome: "Sent", message: "Sent." },
|
|
2548
|
+
]);
|
|
2549
|
+
|
|
2550
|
+
/*
|
|
2551
|
+
* Through the table's own selection sync rather than immediately: the page
|
|
2552
|
+
* drops the ids, the table copies the empty list into its bar on the next
|
|
2553
|
+
* pass. The bar going away is the fact worth asserting; the pass it takes
|
|
2554
|
+
* to get there is not.
|
|
2555
|
+
*/
|
|
2556
|
+
await waitFor((): void => {
|
|
2557
|
+
expect(screen.queryByText("Bulk Actions")).toBeNull();
|
|
2558
|
+
});
|
|
2559
|
+
});
|
|
2560
|
+
|
|
2561
|
+
test("the report can be dismissed", async () => {
|
|
2562
|
+
await runReminderForBoth([
|
|
2563
|
+
{ userId: UNREACHABLE_USER_ID, outcome: "Sent", message: "Sent." },
|
|
2564
|
+
{ userId: PARTIAL_USER_ID, outcome: "Sent", message: "Sent." },
|
|
2565
|
+
]);
|
|
2566
|
+
|
|
2567
|
+
fireEvent.click(screen.getByText("Dismiss"));
|
|
2568
|
+
|
|
2569
|
+
expect(screen.queryByText(/Setup reminders:/)).toBeNull();
|
|
2570
|
+
});
|
|
2571
|
+
});
|
|
2572
|
+
|
|
2573
|
+
describe("the setup-reminder control - when the request itself fails", () => {
|
|
2574
|
+
type FailedRunFunction = () => Promise<void>;
|
|
2575
|
+
|
|
2576
|
+
const runFailingReminder: FailedRunFunction = async (): Promise<void> => {
|
|
2577
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
2578
|
+
postMock.mockRejectedValue(new Error("Network is unreachable") as never);
|
|
2579
|
+
|
|
2580
|
+
await renderProjectPage();
|
|
2581
|
+
|
|
2582
|
+
await screen.findByText("Zed Unreachable");
|
|
2583
|
+
|
|
2584
|
+
selectResponder("Zed Unreachable");
|
|
2585
|
+
runBulkAction("Send setup reminder");
|
|
2586
|
+
fireEvent.click(
|
|
2587
|
+
screen.getByRole("button", { name: "Send setup reminder" }),
|
|
2588
|
+
);
|
|
2589
|
+
|
|
2590
|
+
await waitFor((): void => {
|
|
2591
|
+
expect(
|
|
2592
|
+
screen.getByText(/We could not confirm which reminders were sent\./),
|
|
2593
|
+
).toBeInTheDocument();
|
|
2594
|
+
});
|
|
2595
|
+
};
|
|
2596
|
+
|
|
2597
|
+
test("it does not claim nothing was sent, because it cannot know", async () => {
|
|
2598
|
+
/*
|
|
2599
|
+
* The endpoint fans out up to 250 sequential sends inside ONE request, so a
|
|
2600
|
+
* rejected request does not mean nothing left the building - a gateway
|
|
2601
|
+
* timeout mid-fan-out looks identical here and some mail HAS gone. The old
|
|
2602
|
+
* copy said "No reminders were sent." and was a guess dressed as a fact.
|
|
2603
|
+
*/
|
|
2604
|
+
await runFailingReminder();
|
|
2605
|
+
|
|
2606
|
+
expect(screen.queryByText(/No reminders were sent\./)).toBeNull();
|
|
2607
|
+
});
|
|
2608
|
+
|
|
2609
|
+
test("it says plainly what happened, in the server's words", async () => {
|
|
2610
|
+
await runFailingReminder();
|
|
2611
|
+
|
|
2612
|
+
expect(screen.getByText(/Network is unreachable/)).toBeInTheDocument();
|
|
2613
|
+
});
|
|
2614
|
+
|
|
2615
|
+
test("it never renders a per-user report alongside the failure", async () => {
|
|
2616
|
+
await runFailingReminder();
|
|
2617
|
+
|
|
2618
|
+
expect(screen.queryByText(/Setup reminders:/)).toBeNull();
|
|
2619
|
+
});
|
|
2620
|
+
|
|
2621
|
+
test("the selection survives, so the run can be retried", async () => {
|
|
2622
|
+
await runFailingReminder();
|
|
2623
|
+
|
|
2624
|
+
expect(screen.getByText("1 Responders Selected")).toBeInTheDocument();
|
|
2625
|
+
});
|
|
2626
|
+
|
|
2627
|
+
test("a non-200 answer is a failure, not a silent success", async () => {
|
|
2628
|
+
/*
|
|
2629
|
+
* API.post RESOLVES with an error response rather than throwing for a
|
|
2630
|
+
* non-2xx, so a page that only caught rejections would render a clean
|
|
2631
|
+
* banner over a request the server refused.
|
|
2632
|
+
*/
|
|
2633
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
2634
|
+
postMock.mockResolvedValue(
|
|
2635
|
+
new HTTPErrorResponse(500, { message: "boom" }, {}) as never,
|
|
2636
|
+
);
|
|
2637
|
+
|
|
2638
|
+
await renderProjectPage();
|
|
2639
|
+
|
|
2640
|
+
await screen.findByText("Zed Unreachable");
|
|
2641
|
+
|
|
2642
|
+
selectResponder("Zed Unreachable");
|
|
2643
|
+
runBulkAction("Send setup reminder");
|
|
2644
|
+
fireEvent.click(
|
|
2645
|
+
screen.getByRole("button", { name: "Send setup reminder" }),
|
|
2646
|
+
);
|
|
2647
|
+
|
|
2648
|
+
await waitFor((): void => {
|
|
2649
|
+
expect(
|
|
2650
|
+
screen.getByText(/We could not confirm which reminders were sent\./),
|
|
2651
|
+
).toBeInTheDocument();
|
|
2652
|
+
});
|
|
2653
|
+
|
|
2654
|
+
expect(screen.queryByText(/Setup reminders:/)).toBeNull();
|
|
2655
|
+
});
|
|
2656
|
+
});
|
|
2657
|
+
|
|
2658
|
+
/*
|
|
2659
|
+
* A slow request has to keep the page's shape. A centred spinner where the
|
|
2660
|
+
* table belongs reads as "this feature is broken" rather than "this is still
|
|
2661
|
+
* loading" - the same reason ResponderReadinessCard draws skeletons.
|
|
2662
|
+
*/
|
|
2663
|
+
describe("while loading", () => {
|
|
2664
|
+
test("draws skeletons rather than a bare spinner", async () => {
|
|
2665
|
+
neverRespond();
|
|
2666
|
+
|
|
2667
|
+
const container: HTMLElement = await renderProjectPage();
|
|
2668
|
+
|
|
2669
|
+
expect(
|
|
2670
|
+
container.querySelectorAll(".animate-pulse").length,
|
|
2671
|
+
).toBeGreaterThan(0);
|
|
2672
|
+
});
|
|
2673
|
+
|
|
2674
|
+
test("keeps the card chrome on screen", async () => {
|
|
2675
|
+
neverRespond();
|
|
2676
|
+
|
|
2677
|
+
await renderProjectPage();
|
|
2678
|
+
|
|
2679
|
+
expect(screen.getByText("On-call readiness")).toBeInTheDocument();
|
|
2680
|
+
expect(screen.queryByRole("table")).toBeNull();
|
|
2681
|
+
});
|
|
2682
|
+
});
|
|
2683
|
+
|
|
2684
|
+
/*
|
|
2685
|
+
* Tone follows the value. Hardcoded amber and red tiles paint a warning and a
|
|
2686
|
+
* critical zero on a project where every responder is reachable, which teaches
|
|
2687
|
+
* the reader that the colours here mean nothing - and the day one of them does
|
|
2688
|
+
* mean something, they have already stopped looking.
|
|
2689
|
+
*/
|
|
2690
|
+
describe("the summary tiles", () => {
|
|
2691
|
+
test("a healthy project renders no warning or critical tone", async () => {
|
|
2692
|
+
respondWith(summaryJson([READY_USER]));
|
|
2693
|
+
|
|
2694
|
+
const container: HTMLElement = await renderProjectPage();
|
|
2695
|
+
|
|
2696
|
+
await screen.findByText("Ada Ready");
|
|
2697
|
+
|
|
2698
|
+
const tiles: HTMLElement | null = container.querySelector(
|
|
2699
|
+
"div.mb-6.grid",
|
|
2700
|
+
) as HTMLElement | null;
|
|
2701
|
+
|
|
2702
|
+
if (!tiles) {
|
|
2703
|
+
throw new Error("no summary tile grid");
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
expect(tiles.querySelector(".text-amber-700")).toBeNull();
|
|
2707
|
+
expect(tiles.querySelector(".text-red-700")).toBeNull();
|
|
2708
|
+
expect(tiles.querySelector(".bg-emerald-50")).not.toBeNull();
|
|
2709
|
+
});
|
|
2710
|
+
|
|
2711
|
+
test("a project with real gaps still colours them", async () => {
|
|
2712
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
2713
|
+
|
|
2714
|
+
const container: HTMLElement = await renderProjectPage();
|
|
2715
|
+
|
|
2716
|
+
await screen.findByText("Zed Unreachable");
|
|
2717
|
+
|
|
2718
|
+
const tiles: HTMLElement | null = container.querySelector(
|
|
2719
|
+
"div.mb-6.grid",
|
|
2720
|
+
) as HTMLElement | null;
|
|
2721
|
+
|
|
2722
|
+
if (!tiles) {
|
|
2723
|
+
throw new Error("no summary tile grid");
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
expect(tiles.querySelector(".text-amber-700")).not.toBeNull();
|
|
2727
|
+
expect(tiles.querySelector(".text-red-700")).not.toBeNull();
|
|
2728
|
+
});
|
|
2729
|
+
});
|
|
2730
|
+
|
|
2731
|
+
/*
|
|
2732
|
+
* Filtering from the tiles.
|
|
2733
|
+
*
|
|
2734
|
+
* The tiles already carry the three counts an admin triages by, so they are
|
|
2735
|
+
* the control that filters to those states - one object holding the number and
|
|
2736
|
+
* the action on it. They replaced a private pill strip that no other table in
|
|
2737
|
+
* the product had, and, crucially, they go through the ORDINARY filter: the
|
|
2738
|
+
* result announces itself in the standard chip banner and clears with the
|
|
2739
|
+
* standard Clear Filters, so a filter applied from a tile and one applied from
|
|
2740
|
+
* the modal are the same thing in the same place.
|
|
2741
|
+
*/
|
|
2742
|
+
describe("filtering from the summary tiles", () => {
|
|
2743
|
+
type FilterToUnreachableFunction = () => void;
|
|
2744
|
+
|
|
2745
|
+
const filterToUnreachable: FilterToUnreachableFunction = (): void => {
|
|
2746
|
+
fireEvent.click(
|
|
2747
|
+
screen.getByRole("button", {
|
|
2748
|
+
name: "Filter to responders who are unreachable",
|
|
2749
|
+
}),
|
|
2750
|
+
);
|
|
2751
|
+
};
|
|
2752
|
+
|
|
2753
|
+
test("filtering to unreachable hides the other two", async () => {
|
|
2754
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
2755
|
+
|
|
2756
|
+
await renderProjectPage();
|
|
2757
|
+
|
|
2758
|
+
await screen.findByText("Zed Unreachable");
|
|
2759
|
+
|
|
2760
|
+
filterToUnreachable();
|
|
2761
|
+
|
|
2762
|
+
expect(screen.getByText("Zed Unreachable")).toBeInTheDocument();
|
|
2763
|
+
expect(screen.queryByText("Ada Ready")).toBeNull();
|
|
2764
|
+
expect(screen.queryByText("Jane Partial")).toBeNull();
|
|
2765
|
+
});
|
|
2766
|
+
|
|
2767
|
+
test("a filter that matches nobody says so", async () => {
|
|
2768
|
+
respondWith(summaryJson([READY_USER]));
|
|
2769
|
+
|
|
2770
|
+
await renderProjectPage();
|
|
2771
|
+
|
|
2772
|
+
await screen.findByText("Ada Ready");
|
|
2773
|
+
|
|
2774
|
+
filterToUnreachable();
|
|
2775
|
+
|
|
2776
|
+
expect(
|
|
2777
|
+
screen.getByText("No responders match these filters."),
|
|
2778
|
+
).toBeInTheDocument();
|
|
2779
|
+
});
|
|
2780
|
+
|
|
2781
|
+
/*
|
|
2782
|
+
* A tile-driven filter is a real filter, which means the reader can see it
|
|
2783
|
+
* and undo it from the same place as any other. A private pill strip could
|
|
2784
|
+
* not say this: it announced nothing, so a reader who scrolled past it read
|
|
2785
|
+
* a filtered table as the whole project.
|
|
2786
|
+
*/
|
|
2787
|
+
test("the filter announces itself in the standard chip banner", async () => {
|
|
2788
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
2789
|
+
|
|
2790
|
+
await renderProjectPage();
|
|
2791
|
+
|
|
2792
|
+
await screen.findByText("Zed Unreachable");
|
|
2793
|
+
|
|
2794
|
+
filterToUnreachable();
|
|
2795
|
+
|
|
2796
|
+
expect(
|
|
2797
|
+
screen.getByText("Showing Responders that match"),
|
|
2798
|
+
).toBeInTheDocument();
|
|
2799
|
+
expect(screen.getByText("Clear Filters")).toBeInTheDocument();
|
|
2800
|
+
});
|
|
2801
|
+
|
|
2802
|
+
test("pressing the same tile again clears it", async () => {
|
|
2803
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
2804
|
+
|
|
2805
|
+
await renderProjectPage();
|
|
2806
|
+
|
|
2807
|
+
await screen.findByText("Zed Unreachable");
|
|
2808
|
+
|
|
2809
|
+
filterToUnreachable();
|
|
2810
|
+
|
|
2811
|
+
expect(screen.queryByText("Ada Ready")).toBeNull();
|
|
2812
|
+
|
|
2813
|
+
filterToUnreachable();
|
|
2814
|
+
|
|
2815
|
+
expect(screen.getByText("Ada Ready")).toBeInTheDocument();
|
|
2816
|
+
});
|
|
2817
|
+
|
|
2818
|
+
test("the pressed tile says it is pressed", async () => {
|
|
2819
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
2820
|
+
|
|
2821
|
+
await renderProjectPage();
|
|
2822
|
+
|
|
2823
|
+
await screen.findByText("Zed Unreachable");
|
|
2824
|
+
|
|
2825
|
+
const tile: HTMLElement = screen.getByRole("button", {
|
|
2826
|
+
name: "Filter to responders who are unreachable",
|
|
2827
|
+
});
|
|
2828
|
+
|
|
2829
|
+
expect(tile).toHaveAttribute("aria-pressed", "false");
|
|
2830
|
+
|
|
2831
|
+
filterToUnreachable();
|
|
2832
|
+
|
|
2833
|
+
expect(tile).toHaveAttribute("aria-pressed", "true");
|
|
2834
|
+
});
|
|
2835
|
+
|
|
2836
|
+
/*
|
|
2837
|
+
* The tiles count the WHOLE project, always. Recomputing them from the
|
|
2838
|
+
* filtered rows would make "Unreachable: 1" mean "1 of the rows you can
|
|
2839
|
+
* currently see", so pressing the tile would appear to have found every
|
|
2840
|
+
* unreachable responder in the project when it had only found the ones the
|
|
2841
|
+
* previous filter left behind.
|
|
2842
|
+
*/
|
|
2843
|
+
test("the counts do not shrink to match the filter", async () => {
|
|
2844
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
2845
|
+
|
|
2846
|
+
const container: HTMLElement = await renderProjectPage();
|
|
2847
|
+
|
|
2848
|
+
await screen.findByText("Zed Unreachable");
|
|
2849
|
+
|
|
2850
|
+
filterToUnreachable();
|
|
2851
|
+
|
|
2852
|
+
const tiles: HTMLElement | null = container.querySelector(
|
|
2853
|
+
"div.mb-6.grid",
|
|
2854
|
+
) as HTMLElement | null;
|
|
2855
|
+
|
|
2856
|
+
if (!tiles) {
|
|
2857
|
+
throw new Error("no summary tile grid");
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
// Three responders, one of each state, even though one row is showing.
|
|
2861
|
+
expect(within(tiles).getByText("3")).toBeInTheDocument();
|
|
2862
|
+
expect(within(tiles).getAllByText("1")).toHaveLength(3);
|
|
2863
|
+
});
|
|
2864
|
+
});
|
|
2865
|
+
|
|
2866
|
+
/*
|
|
2867
|
+
* The filter modal - the same one every other table in the product opens from
|
|
2868
|
+
* the same funnel in the same corner.
|
|
2869
|
+
*/
|
|
2870
|
+
describe("the filter modal", () => {
|
|
2871
|
+
/*
|
|
2872
|
+
* The row for one filter, found by its label. The modal holds several
|
|
2873
|
+
* comboboxes and they are indistinguishable by role alone, so every
|
|
2874
|
+
* interaction here is scoped to the row it belongs to - a document-wide
|
|
2875
|
+
* query would drive whichever control happened to render first and pass for
|
|
2876
|
+
* the wrong reason.
|
|
2877
|
+
*/
|
|
2878
|
+
type FilterRowFunction = (title: string) => HTMLElement;
|
|
2879
|
+
|
|
2880
|
+
const filterRow: FilterRowFunction = (title: string): HTMLElement => {
|
|
2881
|
+
const row: HTMLElement | null = screen
|
|
2882
|
+
.getByText(title, { selector: "label" })
|
|
2883
|
+
.closest("div.grid") as HTMLElement | null;
|
|
2884
|
+
|
|
2885
|
+
if (!row) {
|
|
2886
|
+
throw new Error(`no filter row for ${title}`);
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
return row;
|
|
2890
|
+
};
|
|
2891
|
+
|
|
2892
|
+
type PickOptionFunction = (
|
|
2893
|
+
filterTitle: string,
|
|
2894
|
+
optionLabel: string,
|
|
2895
|
+
) => Promise<void>;
|
|
2896
|
+
|
|
2897
|
+
/*
|
|
2898
|
+
* Opening the menu is ArrowDown on the combobox - react-select mounts its
|
|
2899
|
+
* option list lazily and no other interaction brings it into the DOM.
|
|
2900
|
+
*
|
|
2901
|
+
* The options themselves are then found document-wide rather than inside the
|
|
2902
|
+
* row, because react-select PORTALS its menu out of the row it belongs to.
|
|
2903
|
+
* Scoping to the row finds nothing; scoping to the option role is what keeps
|
|
2904
|
+
* this honest, since only one menu is ever open at a time and "Needs setup"
|
|
2905
|
+
* is also a tile label.
|
|
2906
|
+
*/
|
|
2907
|
+
const pickOption: PickOptionFunction = async (
|
|
2908
|
+
filterTitle: string,
|
|
2909
|
+
optionLabel: string,
|
|
2910
|
+
): Promise<void> => {
|
|
2911
|
+
fireEvent.keyDown(within(filterRow(filterTitle)).getByRole("combobox"), {
|
|
2912
|
+
key: "ArrowDown",
|
|
2913
|
+
code: "ArrowDown",
|
|
2914
|
+
});
|
|
2915
|
+
|
|
2916
|
+
const options: Array<HTMLElement> = await screen.findAllByRole("option");
|
|
2917
|
+
|
|
2918
|
+
const option: HTMLElement | undefined = options.find(
|
|
2919
|
+
(candidate: HTMLElement): boolean => {
|
|
2920
|
+
return candidate.textContent === optionLabel;
|
|
2921
|
+
},
|
|
2922
|
+
);
|
|
2923
|
+
|
|
2924
|
+
if (!option) {
|
|
2925
|
+
throw new Error(`no "${optionLabel}" option under ${filterTitle}`);
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
fireEvent.click(option);
|
|
2929
|
+
};
|
|
2930
|
+
|
|
2931
|
+
type ApplyFiltersFunction = () => void;
|
|
2932
|
+
|
|
2933
|
+
const applyFilters: ApplyFiltersFunction = (): void => {
|
|
2934
|
+
fireEvent.click(screen.getByRole("button", { name: "Apply Filters" }));
|
|
2935
|
+
};
|
|
2936
|
+
|
|
2937
|
+
test("the funnel opens it", async () => {
|
|
2938
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
2939
|
+
|
|
2940
|
+
await renderProjectPage();
|
|
2941
|
+
|
|
2942
|
+
await screen.findByText("Zed Unreachable");
|
|
2943
|
+
|
|
2944
|
+
expect(screen.queryByText("Filter Responders")).toBeNull();
|
|
2945
|
+
|
|
2946
|
+
openFilterModal();
|
|
2947
|
+
|
|
2948
|
+
expect(screen.getByText("Filter Responders")).toBeInTheDocument();
|
|
2949
|
+
});
|
|
2950
|
+
|
|
2951
|
+
test("it offers responder, status, team and reached-via", async () => {
|
|
2952
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
2953
|
+
|
|
2954
|
+
await renderProjectPage();
|
|
2955
|
+
|
|
2956
|
+
await screen.findByText("Zed Unreachable");
|
|
2957
|
+
|
|
2958
|
+
openFilterModal();
|
|
2959
|
+
|
|
2960
|
+
expect(filterRow("Responder")).toBeInTheDocument();
|
|
2961
|
+
expect(filterRow("Status")).toBeInTheDocument();
|
|
2962
|
+
expect(filterRow("Team")).toBeInTheDocument();
|
|
2963
|
+
expect(filterRow("Reached via")).toBeInTheDocument();
|
|
2964
|
+
});
|
|
2965
|
+
|
|
2966
|
+
/*
|
|
2967
|
+
* Filtering by a free-text name is the one an admin reaches for when they
|
|
2968
|
+
* are chasing a particular person rather than triaging a state.
|
|
2969
|
+
*/
|
|
2970
|
+
test("the responder filter matches on name", async () => {
|
|
2971
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
2972
|
+
|
|
2973
|
+
await renderProjectPage();
|
|
2974
|
+
|
|
2975
|
+
await screen.findByText("Zed Unreachable");
|
|
2976
|
+
|
|
2977
|
+
openFilterModal();
|
|
2978
|
+
|
|
2979
|
+
fireEvent.change(within(filterRow("Responder")).getByRole("textbox"), {
|
|
2980
|
+
target: { value: "jane" },
|
|
2981
|
+
});
|
|
2982
|
+
|
|
2983
|
+
applyFilters();
|
|
2984
|
+
|
|
2985
|
+
expect(screen.getByText("Jane Partial")).toBeInTheDocument();
|
|
2986
|
+
expect(screen.queryByText("Zed Unreachable")).toBeNull();
|
|
2987
|
+
expect(screen.queryByText("Ada Ready")).toBeNull();
|
|
2988
|
+
});
|
|
2989
|
+
|
|
2990
|
+
/*
|
|
2991
|
+
* The login address, not only the display name. Two people called "J. Smith"
|
|
2992
|
+
* are told apart by their email and by nothing else, and it is the field an
|
|
2993
|
+
* admin is most likely to have in hand when somebody hands them a ticket.
|
|
2994
|
+
*/
|
|
2995
|
+
test("the responder filter matches on the login email too", async () => {
|
|
2996
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
2997
|
+
|
|
2998
|
+
await renderProjectPage();
|
|
2999
|
+
|
|
3000
|
+
await screen.findByText("Zed Unreachable");
|
|
3001
|
+
|
|
3002
|
+
openFilterModal();
|
|
3003
|
+
|
|
3004
|
+
fireEvent.change(within(filterRow("Responder")).getByRole("textbox"), {
|
|
3005
|
+
target: { value: "zed.unreachable@example.com" },
|
|
3006
|
+
});
|
|
3007
|
+
|
|
3008
|
+
applyFilters();
|
|
3009
|
+
|
|
3010
|
+
expect(screen.getByText("Zed Unreachable")).toBeInTheDocument();
|
|
3011
|
+
expect(screen.queryByText("Jane Partial")).toBeNull();
|
|
3012
|
+
});
|
|
3013
|
+
|
|
3014
|
+
test("filtering by status narrows the table", async () => {
|
|
3015
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
3016
|
+
|
|
3017
|
+
await renderProjectPage();
|
|
3018
|
+
|
|
3019
|
+
await screen.findByText("Zed Unreachable");
|
|
3020
|
+
|
|
3021
|
+
openFilterModal();
|
|
3022
|
+
|
|
3023
|
+
await pickOption("Status", "Needs setup");
|
|
3024
|
+
|
|
3025
|
+
applyFilters();
|
|
3026
|
+
|
|
3027
|
+
expect(screen.getByText("Jane Partial")).toBeInTheDocument();
|
|
3028
|
+
expect(screen.queryByText("Zed Unreachable")).toBeNull();
|
|
3029
|
+
expect(screen.queryByText("Ada Ready")).toBeNull();
|
|
3030
|
+
});
|
|
3031
|
+
|
|
3032
|
+
test("filtering by how a responder is reached narrows the table", async () => {
|
|
3033
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
3034
|
+
|
|
3035
|
+
await renderProjectPage();
|
|
3036
|
+
|
|
3037
|
+
await screen.findByText("Zed Unreachable");
|
|
3038
|
+
|
|
3039
|
+
openFilterModal();
|
|
3040
|
+
|
|
3041
|
+
await pickOption("Reached via", "Schedule");
|
|
3042
|
+
|
|
3043
|
+
applyFilters();
|
|
3044
|
+
|
|
3045
|
+
expect(screen.getByText("Jane Partial")).toBeInTheDocument();
|
|
3046
|
+
expect(screen.queryByText("Zed Unreachable")).toBeNull();
|
|
3047
|
+
});
|
|
3048
|
+
});
|
|
3049
|
+
|
|
3050
|
+
/*
|
|
3051
|
+
* Filtering by team.
|
|
3052
|
+
*
|
|
3053
|
+
* "Team" here means the on-call teams that PAGE a responder, not every team
|
|
3054
|
+
* they belong to, and the difference is what makes the filter worth having: a
|
|
3055
|
+
* team with no escalation rule attached to it pages nobody, so offering it
|
|
3056
|
+
* would be an option that always answers with an empty table.
|
|
3057
|
+
*/
|
|
3058
|
+
describe("filtering by team", () => {
|
|
3059
|
+
type OpenTeamFilterFunction = () => Promise<void>;
|
|
3060
|
+
|
|
3061
|
+
const teamRow: () => HTMLElement = (): HTMLElement => {
|
|
3062
|
+
const row: HTMLElement | null = screen
|
|
3063
|
+
.getByText("Team", { selector: "label" })
|
|
3064
|
+
.closest("div.grid") as HTMLElement | null;
|
|
3065
|
+
|
|
3066
|
+
if (!row) {
|
|
3067
|
+
throw new Error("no team filter row");
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
return row;
|
|
3071
|
+
};
|
|
3072
|
+
|
|
3073
|
+
const openTeamFilter: OpenTeamFilterFunction = async (): Promise<void> => {
|
|
3074
|
+
respondWith(
|
|
3075
|
+
summaryJson([
|
|
3076
|
+
READY_USER,
|
|
3077
|
+
PARTIAL_USER,
|
|
3078
|
+
UNREACHABLE_USER,
|
|
3079
|
+
TWO_TEAM_USER,
|
|
3080
|
+
]),
|
|
3081
|
+
);
|
|
3082
|
+
|
|
3083
|
+
await renderProjectPage();
|
|
3084
|
+
|
|
3085
|
+
await screen.findByText("Ravi Twoteams");
|
|
3086
|
+
|
|
3087
|
+
openFilterModal();
|
|
3088
|
+
|
|
3089
|
+
fireEvent.keyDown(within(teamRow()).getByRole("combobox"), {
|
|
3090
|
+
key: "ArrowDown",
|
|
3091
|
+
code: "ArrowDown",
|
|
3092
|
+
});
|
|
3093
|
+
};
|
|
3094
|
+
|
|
3095
|
+
type PickTeamFunction = (name: string) => Promise<void>;
|
|
3096
|
+
|
|
3097
|
+
/*
|
|
3098
|
+
* The menu is portalled out of the row, so the option is found by role
|
|
3099
|
+
* document-wide. Only one menu is open at a time, and a team name is also a
|
|
3100
|
+
* chip in the table - the role is what tells the two apart.
|
|
3101
|
+
*/
|
|
3102
|
+
const pickTeam: PickTeamFunction = async (name: string): Promise<void> => {
|
|
3103
|
+
fireEvent.keyDown(within(teamRow()).getByRole("combobox"), {
|
|
3104
|
+
key: "ArrowDown",
|
|
3105
|
+
code: "ArrowDown",
|
|
3106
|
+
});
|
|
3107
|
+
|
|
3108
|
+
const option: HTMLElement | undefined = (
|
|
3109
|
+
await screen.findAllByRole("option")
|
|
3110
|
+
).find((candidate: HTMLElement): boolean => {
|
|
3111
|
+
return candidate.textContent === name;
|
|
3112
|
+
});
|
|
3113
|
+
|
|
3114
|
+
if (!option) {
|
|
3115
|
+
throw new Error(`no "${name}" option in the team filter`);
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
fireEvent.click(option);
|
|
3119
|
+
};
|
|
3120
|
+
|
|
3121
|
+
const applyFilters: () => void = (): void => {
|
|
3122
|
+
fireEvent.click(screen.getByRole("button", { name: "Apply Filters" }));
|
|
3123
|
+
};
|
|
3124
|
+
|
|
3125
|
+
/*
|
|
3126
|
+
* The options come from the rows themselves rather than from the project's
|
|
3127
|
+
* team list. Every option therefore has at least one responder behind it,
|
|
3128
|
+
* where a project-wide list would offer teams that page nobody.
|
|
3129
|
+
*/
|
|
3130
|
+
test("the options are the teams that actually page somebody", async () => {
|
|
3131
|
+
await openTeamFilter();
|
|
3132
|
+
|
|
3133
|
+
const options: Array<string> = (await screen.findAllByRole("option")).map(
|
|
3134
|
+
(option: HTMLElement): string => {
|
|
3135
|
+
return option.textContent || "";
|
|
3136
|
+
},
|
|
3137
|
+
);
|
|
3138
|
+
|
|
3139
|
+
expect(options).toEqual(["Payments", "Platform"]);
|
|
3140
|
+
});
|
|
3141
|
+
|
|
3142
|
+
test("picking a team hides the responders it does not page", async () => {
|
|
3143
|
+
await openTeamFilter();
|
|
3144
|
+
|
|
3145
|
+
await pickTeam("Payments");
|
|
3146
|
+
|
|
3147
|
+
applyFilters();
|
|
3148
|
+
|
|
3149
|
+
expect(screen.getByText("Ravi Twoteams")).toBeInTheDocument();
|
|
3150
|
+
expect(screen.queryByText("Ada Ready")).toBeNull();
|
|
3151
|
+
expect(screen.queryByText("Jane Partial")).toBeNull();
|
|
3152
|
+
expect(screen.queryByText("Zed Unreachable")).toBeNull();
|
|
3153
|
+
});
|
|
3154
|
+
|
|
3155
|
+
/*
|
|
3156
|
+
* A responder on two attached teams is paged by both, so they belong under
|
|
3157
|
+
* either filter. An "equals" comparison could not express that at all, which
|
|
3158
|
+
* is why every filter on this page is a set intersection.
|
|
3159
|
+
*/
|
|
3160
|
+
test("a responder on two teams appears under either of them", async () => {
|
|
3161
|
+
await openTeamFilter();
|
|
3162
|
+
|
|
3163
|
+
await pickTeam("Platform");
|
|
3164
|
+
|
|
3165
|
+
applyFilters();
|
|
3166
|
+
|
|
3167
|
+
expect(screen.getByText("Ravi Twoteams")).toBeInTheDocument();
|
|
3168
|
+
expect(screen.getByText("Ada Ready")).toBeInTheDocument();
|
|
3169
|
+
expect(screen.queryByText("Jane Partial")).toBeNull();
|
|
3170
|
+
});
|
|
3171
|
+
|
|
3172
|
+
test("two teams at once is a union, not an intersection", async () => {
|
|
3173
|
+
await openTeamFilter();
|
|
3174
|
+
|
|
3175
|
+
await pickTeam("Platform");
|
|
3176
|
+
await pickTeam("Payments");
|
|
3177
|
+
|
|
3178
|
+
applyFilters();
|
|
3179
|
+
|
|
3180
|
+
expect(screen.getByText("Ravi Twoteams")).toBeInTheDocument();
|
|
3181
|
+
expect(screen.getByText("Ada Ready")).toBeInTheDocument();
|
|
3182
|
+
expect(screen.queryByText("Zed Unreachable")).toBeNull();
|
|
3183
|
+
});
|
|
3184
|
+
|
|
3185
|
+
/*
|
|
3186
|
+
* Filters compose. Team AND status is the question an admin actually has -
|
|
3187
|
+
* "who on Platform cannot be paged" - and answering it must not silently
|
|
3188
|
+
* drop one half of it.
|
|
3189
|
+
*/
|
|
3190
|
+
test("team and status narrow together", async () => {
|
|
3191
|
+
await openTeamFilter();
|
|
3192
|
+
|
|
3193
|
+
await pickTeam("Platform");
|
|
3194
|
+
|
|
3195
|
+
const statusRow: HTMLElement | null = screen
|
|
3196
|
+
.getByText("Status", { selector: "label" })
|
|
3197
|
+
.closest("div.grid") as HTMLElement | null;
|
|
3198
|
+
|
|
3199
|
+
if (!statusRow) {
|
|
3200
|
+
throw new Error("no status filter row");
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
fireEvent.keyDown(within(statusRow).getByRole("combobox"), {
|
|
3204
|
+
key: "ArrowDown",
|
|
3205
|
+
code: "ArrowDown",
|
|
3206
|
+
});
|
|
3207
|
+
|
|
3208
|
+
const statusOption: HTMLElement | undefined = (
|
|
3209
|
+
await screen.findAllByRole("option")
|
|
3210
|
+
).find((candidate: HTMLElement): boolean => {
|
|
3211
|
+
return candidate.textContent === "Needs setup";
|
|
3212
|
+
});
|
|
3213
|
+
|
|
3214
|
+
if (!statusOption) {
|
|
3215
|
+
throw new Error("no needs-setup option in the status filter");
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
fireEvent.click(statusOption);
|
|
3219
|
+
|
|
3220
|
+
applyFilters();
|
|
3221
|
+
|
|
3222
|
+
expect(screen.getByText("Ravi Twoteams")).toBeInTheDocument();
|
|
3223
|
+
expect(screen.queryByText("Ada Ready")).toBeNull();
|
|
3224
|
+
});
|
|
3225
|
+
});
|
|
3226
|
+
|
|
3227
|
+
/*
|
|
3228
|
+
* The teams column - the same fact the filter reads, rendered.
|
|
3229
|
+
*/
|
|
3230
|
+
describe("the teams column", () => {
|
|
3231
|
+
test("a responder's teams are named in their row", async () => {
|
|
3232
|
+
respondWith(summaryJson([TWO_TEAM_USER]));
|
|
3233
|
+
|
|
3234
|
+
await renderProjectPage();
|
|
3235
|
+
|
|
3236
|
+
await screen.findByText("Ravi Twoteams");
|
|
3237
|
+
|
|
3238
|
+
const row: HTMLElement = tableRowFor("Ravi Twoteams");
|
|
3239
|
+
|
|
3240
|
+
expect(within(row).getByText("Platform")).toBeInTheDocument();
|
|
3241
|
+
expect(within(row).getByText("Payments")).toBeInTheDocument();
|
|
3242
|
+
});
|
|
3243
|
+
|
|
3244
|
+
/*
|
|
3245
|
+
* A responder reached directly or through a schedule has no team involved at
|
|
3246
|
+
* all, so an empty cell is not a gap to be fixed. It renders as a dash rather
|
|
3247
|
+
* than as "None", which would read like something is missing.
|
|
3248
|
+
*/
|
|
3249
|
+
test("a responder no team pages renders a dash, not an accusation", async () => {
|
|
3250
|
+
respondWith(summaryJson([UNREACHABLE_USER]));
|
|
3251
|
+
|
|
3252
|
+
await renderProjectPage();
|
|
3253
|
+
|
|
3254
|
+
await screen.findByText("Zed Unreachable");
|
|
3255
|
+
|
|
3256
|
+
const row: HTMLElement = tableRowFor("Zed Unreachable");
|
|
3257
|
+
|
|
3258
|
+
expect(within(row).getByText("—")).toBeInTheDocument();
|
|
3259
|
+
});
|
|
3260
|
+
});
|
|
3261
|
+
|
|
3262
|
+
/*
|
|
3263
|
+
* Sorting and paging - two things the hand-rolled table never had. The old one
|
|
3264
|
+
* showed 25 rows behind a "Show more" button and could not be reordered at all,
|
|
3265
|
+
* so an admin looking for one name in a project of two hundred had to scroll.
|
|
3266
|
+
*/
|
|
3267
|
+
describe("sorting and paging", () => {
|
|
3268
|
+
type ManyUsersFunction = (count: number) => JSONArray;
|
|
3269
|
+
|
|
3270
|
+
const manyUsers: ManyUsersFunction = (count: number): JSONArray => {
|
|
3271
|
+
const users: JSONArray = [];
|
|
3272
|
+
|
|
3273
|
+
for (let index: number = 0; index < count; index++) {
|
|
3274
|
+
users.push({
|
|
3275
|
+
...UNREACHABLE_USER,
|
|
3276
|
+
userId: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`,
|
|
3277
|
+
userName: `Responder ${String(index).padStart(3, "0")}`,
|
|
3278
|
+
userEmail: `responder${index}@example.com`,
|
|
3279
|
+
});
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
return users;
|
|
3283
|
+
};
|
|
3284
|
+
|
|
3285
|
+
test("the first page holds one page of rows, not all of them", async () => {
|
|
3286
|
+
respondWith(summaryJson(manyUsers(30)));
|
|
3287
|
+
|
|
3288
|
+
const container: HTMLElement = await renderProjectPage();
|
|
3289
|
+
|
|
3290
|
+
await screen.findByText("Responder 000");
|
|
3291
|
+
|
|
3292
|
+
expect(container.querySelectorAll("tbody tr")).toHaveLength(25);
|
|
3293
|
+
expect(screen.queryByText("Responder 029")).toBeNull();
|
|
3294
|
+
});
|
|
3295
|
+
|
|
3296
|
+
test("the rest are one page away", async () => {
|
|
3297
|
+
respondWith(summaryJson(manyUsers(30)));
|
|
3298
|
+
|
|
3299
|
+
await renderProjectPage();
|
|
3300
|
+
|
|
3301
|
+
await screen.findByText("Responder 000");
|
|
3302
|
+
|
|
3303
|
+
/*
|
|
3304
|
+
* Named by aria-label, and rendered twice - desktop and mobile layouts,
|
|
3305
|
+
* separated by CSS alone - so the first one is the one on screen at this
|
|
3306
|
+
* viewport.
|
|
3307
|
+
*/
|
|
3308
|
+
fireEvent.click(
|
|
3309
|
+
screen.getAllByRole("button", { name: "Go to next page" })[0]!,
|
|
3310
|
+
);
|
|
3311
|
+
|
|
3312
|
+
expect(await screen.findByText("Responder 029")).toBeInTheDocument();
|
|
3313
|
+
expect(screen.queryByText("Responder 000")).toBeNull();
|
|
3314
|
+
});
|
|
3315
|
+
|
|
3316
|
+
/*
|
|
3317
|
+
* The default order is the page's editorial position - unreachable first,
|
|
3318
|
+
* then the most incomplete - and clicking a header replaces it. Both have to
|
|
3319
|
+
* work: an admin triaging wants the default, and an admin hunting for a name
|
|
3320
|
+
* wants the alphabet.
|
|
3321
|
+
*/
|
|
3322
|
+
test("clicking a column header reorders the table", async () => {
|
|
3323
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
3324
|
+
|
|
3325
|
+
const container: HTMLElement = await renderProjectPage();
|
|
3326
|
+
|
|
3327
|
+
await screen.findByText("Zed Unreachable");
|
|
3328
|
+
|
|
3329
|
+
fireEvent.click(screen.getByText("Responder"));
|
|
3330
|
+
|
|
3331
|
+
await waitFor((): void => {
|
|
3332
|
+
const rows: Array<string> = Array.from(
|
|
3333
|
+
container.querySelectorAll("tbody tr"),
|
|
3334
|
+
).map((row: Element): string => {
|
|
3335
|
+
return row.textContent || "";
|
|
3336
|
+
});
|
|
3337
|
+
|
|
3338
|
+
expect(rows[0]).toContain("Ada Ready");
|
|
3339
|
+
});
|
|
3340
|
+
});
|
|
3341
|
+
});
|
|
3342
|
+
|
|
3343
|
+
/*
|
|
3344
|
+
* The coverage grid, in a modal rather than an expanded row.
|
|
3345
|
+
*/
|
|
3346
|
+
describe("the coverage modal", () => {
|
|
3347
|
+
test("it is closed until the row's own button opens it", async () => {
|
|
3348
|
+
respondWith(summaryJson([PARTIAL_USER]));
|
|
3349
|
+
|
|
3350
|
+
await renderProjectPage();
|
|
3351
|
+
|
|
3352
|
+
await screen.findByText("Jane Partial");
|
|
3353
|
+
|
|
3354
|
+
expect(screen.queryByText("Incident severities")).toBeNull();
|
|
3355
|
+
|
|
3356
|
+
openCoverage("Jane Partial");
|
|
3357
|
+
|
|
3358
|
+
expect(screen.getByText("Incident severities")).toBeInTheDocument();
|
|
3359
|
+
});
|
|
3360
|
+
|
|
3361
|
+
test("it names the responder it is about", async () => {
|
|
3362
|
+
respondWith(summaryJson([PARTIAL_USER]));
|
|
3363
|
+
|
|
3364
|
+
await renderProjectPage();
|
|
3365
|
+
|
|
3366
|
+
await screen.findByText("Jane Partial");
|
|
3367
|
+
|
|
3368
|
+
openCoverage("Jane Partial");
|
|
3369
|
+
|
|
3370
|
+
expect(screen.getByText("Coverage for Jane Partial")).toBeInTheDocument();
|
|
3371
|
+
});
|
|
3372
|
+
|
|
3373
|
+
test("it carries the reasons behind the status", async () => {
|
|
3374
|
+
respondWith(summaryJson([PARTIAL_USER]));
|
|
3375
|
+
|
|
3376
|
+
await renderProjectPage();
|
|
3377
|
+
|
|
3378
|
+
await screen.findByText("Jane Partial");
|
|
3379
|
+
|
|
3380
|
+
openCoverage("Jane Partial");
|
|
3381
|
+
|
|
3382
|
+
expect(
|
|
3383
|
+
screen.getByText("No notification rule for alerts at Sev1."),
|
|
3384
|
+
).toBeInTheDocument();
|
|
3385
|
+
});
|
|
3386
|
+
|
|
3387
|
+
test("it closes again", async () => {
|
|
3388
|
+
respondWith(summaryJson([PARTIAL_USER]));
|
|
3389
|
+
|
|
3390
|
+
await renderProjectPage();
|
|
3391
|
+
|
|
3392
|
+
await screen.findByText("Jane Partial");
|
|
3393
|
+
|
|
3394
|
+
openCoverage("Jane Partial");
|
|
3395
|
+
|
|
3396
|
+
/*
|
|
3397
|
+
* The footer button. The header carries a close icon labelled "Close" too,
|
|
3398
|
+
* and either one shuts the modal - this is the one a reader reaches for.
|
|
3399
|
+
*/
|
|
3400
|
+
fireEvent.click(screen.getByTestId("modal-footer-submit-button"));
|
|
3401
|
+
|
|
3402
|
+
expect(screen.queryByText("Incident severities")).toBeNull();
|
|
3403
|
+
});
|
|
3404
|
+
|
|
3405
|
+
/*
|
|
3406
|
+
* A responder with nothing set up at all cannot be fixed by an admin - only
|
|
3407
|
+
* they can add a notification method - so the panel says that rather than
|
|
3408
|
+
* offering an edit that does not exist.
|
|
3409
|
+
*/
|
|
3410
|
+
test("a responder with no methods is told who can fix it", async () => {
|
|
3411
|
+
respondWith(summaryJson([UNREACHABLE_USER]));
|
|
3412
|
+
|
|
3413
|
+
await renderProjectPage();
|
|
3414
|
+
|
|
3415
|
+
await screen.findByText("Zed Unreachable");
|
|
3416
|
+
|
|
3417
|
+
openCoverage("Zed Unreachable");
|
|
3418
|
+
|
|
3419
|
+
expect(
|
|
3420
|
+
screen.getByText(/No notification methods at all/),
|
|
3421
|
+
).toBeInTheDocument();
|
|
3422
|
+
});
|
|
3423
|
+
});
|
|
3424
|
+
});
|
|
3425
|
+
|
|
3426
|
+
/*
|
|
3427
|
+
* Parsing the teams a responder is paged through.
|
|
3428
|
+
*
|
|
3429
|
+
* This list is what builds the readiness table's team filter - its options AND
|
|
3430
|
+
* the values those options submit - so an entry that survives parsing without
|
|
3431
|
+
* both halves is an option nobody can choose deliberately. The parse therefore
|
|
3432
|
+
* drops rather than repairs, in both directions, and the browser half of that
|
|
3433
|
+
* rule is asserted here independently of the server half.
|
|
3434
|
+
*/
|
|
3435
|
+
describe("parsing a responder's teams", () => {
|
|
3436
|
+
type TeamNamesFunction = (teams: unknown) => Array<string>;
|
|
3437
|
+
|
|
3438
|
+
const teamNamesFrom: TeamNamesFunction = (teams: unknown): Array<string> => {
|
|
3439
|
+
return toUserReadiness({
|
|
3440
|
+
...READY_USER,
|
|
3441
|
+
teams: teams as JSONArray,
|
|
3442
|
+
}).teams.map((team: { name: string }): string => {
|
|
3443
|
+
return team.name;
|
|
3444
|
+
});
|
|
3445
|
+
};
|
|
3446
|
+
|
|
3447
|
+
test("a well-formed team survives with both halves", () => {
|
|
3448
|
+
const parsed: UserReadinessWire = toUserReadiness({
|
|
3449
|
+
...READY_USER,
|
|
3450
|
+
teams: [PLATFORM_TEAM],
|
|
3451
|
+
});
|
|
3452
|
+
|
|
3453
|
+
expect(parsed.teams).toEqual([{ _id: PLATFORM_TEAM_ID, name: "Platform" }]);
|
|
3454
|
+
});
|
|
3455
|
+
|
|
3456
|
+
/*
|
|
3457
|
+
* An id wrapped by ObjectID.toJSON() is flattened rather than stringified into
|
|
3458
|
+
* "[object Object]". A filter carrying that value matches nothing, and matching
|
|
3459
|
+
* nothing looks exactly like a team with no responders.
|
|
3460
|
+
*/
|
|
3461
|
+
test("an ObjectID-wrapped id is flattened to the plain string", () => {
|
|
3462
|
+
const parsed: UserReadinessWire = toUserReadiness({
|
|
3463
|
+
...READY_USER,
|
|
3464
|
+
teams: [
|
|
3465
|
+
{
|
|
3466
|
+
_id: { _type: "ObjectID", value: PLATFORM_TEAM_ID },
|
|
3467
|
+
name: "Platform",
|
|
3468
|
+
},
|
|
3469
|
+
] as JSONArray,
|
|
3470
|
+
});
|
|
3471
|
+
|
|
3472
|
+
expect(parsed.teams[0]!._id).toBe(PLATFORM_TEAM_ID);
|
|
3473
|
+
});
|
|
3474
|
+
|
|
3475
|
+
test("a team with no name is dropped rather than rendered blank", () => {
|
|
3476
|
+
expect(teamNamesFrom([{ _id: PLATFORM_TEAM_ID }, PAYMENTS_TEAM])).toEqual([
|
|
3477
|
+
"Payments",
|
|
3478
|
+
]);
|
|
3479
|
+
});
|
|
3480
|
+
|
|
3481
|
+
test("a team with no id is dropped rather than rendered unfilterable", () => {
|
|
3482
|
+
expect(teamNamesFrom([{ name: "Ghost" }, PAYMENTS_TEAM])).toEqual([
|
|
3483
|
+
"Payments",
|
|
3484
|
+
]);
|
|
3485
|
+
});
|
|
3486
|
+
|
|
3487
|
+
/*
|
|
3488
|
+
* A browser holding this bundle can be talking to a server that predates the
|
|
3489
|
+
* field. An empty list is the only reading of "not sent" that does not crash a
|
|
3490
|
+
* render or invent a team.
|
|
3491
|
+
*/
|
|
3492
|
+
test("a payload with no teams field at all parses to an empty list", () => {
|
|
3493
|
+
const withoutTeams: JSONObject = { ...READY_USER };
|
|
3494
|
+
delete withoutTeams["teams"];
|
|
3495
|
+
|
|
3496
|
+
expect(toUserReadiness(withoutTeams).teams).toEqual([]);
|
|
3497
|
+
});
|
|
3498
|
+
|
|
3499
|
+
test("a teams field that is not an array parses to an empty list", () => {
|
|
3500
|
+
expect(teamNamesFrom("Platform")).toEqual([]);
|
|
3501
|
+
});
|
|
3502
|
+
});
|
|
3503
|
+
|
|
3504
|
+
/*
|
|
3505
|
+
* The render-layer masking guard.
|
|
3506
|
+
*
|
|
3507
|
+
* Deliberately independent of the server's masking tests: those prove the
|
|
3508
|
+
* service masks, these prove the browser never un-masks. Every fixture method
|
|
3509
|
+
* carries the raw value alongside the masked one under three plausible field
|
|
3510
|
+
* names, so a component that starts reading `identifier`, or a parse that starts
|
|
3511
|
+
* copying unknown keys through, fails here.
|
|
3512
|
+
*/
|
|
3513
|
+
describe("no unmasked identifier reaches the DOM", () => {
|
|
3514
|
+
type AssertNoRawIdentifiersFunction = (container: HTMLElement) => void;
|
|
3515
|
+
|
|
3516
|
+
const assertNoRawIdentifiers: AssertNoRawIdentifiersFunction = (
|
|
3517
|
+
container: HTMLElement,
|
|
3518
|
+
): void => {
|
|
3519
|
+
/*
|
|
3520
|
+
* innerHTML rather than textContent, because a leak is just as real in an
|
|
3521
|
+
* href, a title or a data attribute as it is in visible copy.
|
|
3522
|
+
*/
|
|
3523
|
+
for (const raw of ALL_RAW_IDENTIFIERS) {
|
|
3524
|
+
expect(container.innerHTML).not.toContain(raw);
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3527
|
+
/*
|
|
3528
|
+
* The scan above catches the planted values by name; these two catch the
|
|
3529
|
+
* shape, so an identifier this file never thought of is caught too.
|
|
3530
|
+
*/
|
|
3531
|
+
expect(container.textContent || "").not.toMatch(UNMASKED_EMAIL_PATTERN);
|
|
3532
|
+
expect(container.textContent || "").not.toMatch(UNMASKED_PHONE_PATTERN);
|
|
3533
|
+
};
|
|
3534
|
+
|
|
3535
|
+
test("the policy card renders masked identifiers only", async () => {
|
|
3536
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
3537
|
+
|
|
3538
|
+
const container: HTMLElement = await renderPolicyCard();
|
|
3539
|
+
|
|
3540
|
+
await screen.findByText("Zed Unreachable");
|
|
3541
|
+
|
|
3542
|
+
// The masked forms are present, so this is not passing by rendering nothing.
|
|
3543
|
+
expect(container.textContent).toContain(MASKED_EMAIL);
|
|
3544
|
+
expect(container.textContent).toContain(MASKED_PHONE);
|
|
3545
|
+
|
|
3546
|
+
assertNoRawIdentifiers(container);
|
|
3547
|
+
});
|
|
3548
|
+
|
|
3549
|
+
test("the readiness page renders masked identifiers only", async () => {
|
|
3550
|
+
respondWith(summaryJson(ALL_THREE_USERS));
|
|
3551
|
+
|
|
3552
|
+
const container: HTMLElement = await renderProjectPage();
|
|
3553
|
+
|
|
3554
|
+
await screen.findByText("Zed Unreachable");
|
|
3555
|
+
|
|
3556
|
+
openCoverage("Jane Partial");
|
|
3557
|
+
|
|
3558
|
+
expect(container.textContent).toContain(MASKED_EMAIL);
|
|
3559
|
+
expect(container.textContent).toContain(MASKED_PHONE);
|
|
3560
|
+
expect(container.textContent).toContain(MASKED_TELEGRAM);
|
|
3561
|
+
|
|
3562
|
+
assertNoRawIdentifiers(container);
|
|
3563
|
+
});
|
|
3564
|
+
|
|
3565
|
+
/*
|
|
3566
|
+
* The unverified method is the one most likely to be rendered carelessly,
|
|
3567
|
+
* since it is the only one drawn with an extra badge beside it.
|
|
3568
|
+
*/
|
|
3569
|
+
test("an unverified method is labelled, still masked", async () => {
|
|
3570
|
+
respondWith(summaryJson([PARTIAL_USER]));
|
|
3571
|
+
|
|
3572
|
+
const container: HTMLElement = await renderProjectPage();
|
|
3573
|
+
|
|
3574
|
+
await screen.findByText("Jane Partial");
|
|
3575
|
+
|
|
3576
|
+
openCoverage("Jane Partial");
|
|
3577
|
+
|
|
3578
|
+
expect(screen.getByText("Unverified")).toBeInTheDocument();
|
|
3579
|
+
assertNoRawIdentifiers(container);
|
|
3580
|
+
});
|
|
3581
|
+
|
|
3582
|
+
/*
|
|
3583
|
+
* The mail draft is the one place a whole address is legitimate - it is the
|
|
3584
|
+
* mailto recipient, and it is the login email an admin can already read on
|
|
3585
|
+
* every other screen. It must never be a notification-method identifier.
|
|
3586
|
+
*/
|
|
3587
|
+
test("the mail draft carries the login address and no method identifier", async () => {
|
|
3588
|
+
respondWith(summaryJson([PARTIAL_USER]));
|
|
3589
|
+
|
|
3590
|
+
await renderPolicyCard();
|
|
3591
|
+
|
|
3592
|
+
await screen.findByText("Jane Partial");
|
|
3593
|
+
|
|
3594
|
+
const href: string = decodeURIComponent(
|
|
3595
|
+
cardRowFor("Jane Partial")
|
|
3596
|
+
.querySelector('a[href^="mailto:"]')
|
|
3597
|
+
?.getAttribute("href") || "",
|
|
3598
|
+
);
|
|
3599
|
+
|
|
3600
|
+
expect(href).toContain("jane.partial@example.com");
|
|
3601
|
+
|
|
3602
|
+
for (const raw of ALL_RAW_IDENTIFIERS) {
|
|
3603
|
+
expect(href).not.toContain(raw);
|
|
3604
|
+
}
|
|
3605
|
+
});
|
|
3606
|
+
});
|