@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.
Files changed (873) hide show
  1. package/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.ts +1 -1
  2. package/Models/AnalyticsModels/MetricItemAggMV1mByService.ts +1 -1
  3. package/Models/DatabaseModels/AIConversation.ts +32 -0
  4. package/Models/DatabaseModels/AIConversationMessage.ts +41 -0
  5. package/Models/DatabaseModels/AlertEpisodeMember.ts +27 -0
  6. package/Models/DatabaseModels/IncidentEpisodeMember.ts +28 -0
  7. package/Models/DatabaseModels/Index.ts +12 -4
  8. package/Models/DatabaseModels/{TelemetryEntity.ts → InventoryItem.ts} +184 -9
  9. package/Models/DatabaseModels/InventoryItemCustomField.ts +434 -0
  10. package/Models/DatabaseModels/{TelemetryEntityRelationship.ts → InventoryItemRelationship.ts} +33 -7
  11. package/Models/DatabaseModels/NetworkDevice.ts +141 -0
  12. package/Models/DatabaseModels/NetworkDeviceLink.ts +699 -0
  13. package/Models/DatabaseModels/NetworkDeviceLinkRule.ts +467 -0
  14. package/Models/DatabaseModels/NetworkTopologySuppression.ts +429 -0
  15. package/Models/DatabaseModels/OnCallDutyPolicyFeed.ts +9 -0
  16. package/Models/DatabaseModels/Project.ts +78 -0
  17. package/Models/DatabaseModels/UserCall.ts +42 -0
  18. package/Models/DatabaseModels/UserEmail.ts +44 -0
  19. package/Models/DatabaseModels/UserNotificationRule.ts +425 -55
  20. package/Models/DatabaseModels/UserOnCallLogTimeline.ts +24 -2
  21. package/Models/DatabaseModels/UserPush.ts +49 -0
  22. package/Models/DatabaseModels/UserSMS.ts +43 -0
  23. package/Models/DatabaseModels/UserTelegram.ts +59 -0
  24. package/Models/DatabaseModels/UserWebhook.ts +52 -0
  25. package/Models/DatabaseModels/UserWhatsApp.ts +41 -0
  26. package/Models/DatabaseModels/WorkflowLog.ts +38 -0
  27. package/Models/DatabaseModels/WorkflowVariable.ts +12 -0
  28. package/Server/API/AIChatAPI.ts +315 -1
  29. package/Server/API/DashboardAPI.ts +217 -1
  30. package/Server/API/OnCallReadinessAPI.ts +841 -0
  31. package/Server/API/TeamComplianceAPI.ts +69 -17
  32. package/Server/API/TelemetryAPI.ts +220 -12
  33. package/Server/API/UserAPI.ts +16 -1
  34. package/Server/EnvironmentConfig.ts +52 -0
  35. package/Server/Infrastructure/Postgres/DataSourceOptions.ts +22 -0
  36. package/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.ts +4 -4
  37. package/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.ts +5 -5
  38. package/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.ts +41 -0
  39. package/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.ts +17 -0
  40. package/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.ts +35 -0
  41. package/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.ts +82 -0
  42. package/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.ts +91 -0
  43. package/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.ts +47 -0
  44. package/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.ts +255 -0
  45. package/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.ts +107 -0
  46. package/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.ts +90 -0
  47. package/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.ts +39 -0
  48. package/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.ts +33 -0
  49. package/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.ts +59 -0
  50. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +24 -0
  51. package/Server/Infrastructure/Queue.ts +78 -13
  52. package/Server/Middleware/MasterAdminAuthorization.ts +11 -6
  53. package/Server/Middleware/PublicDashboardRateLimit.ts +593 -0
  54. package/Server/Services/AIService.ts +7 -0
  55. package/Server/Services/AlertEpisodeStateTimelineService.ts +29 -0
  56. package/Server/Services/AlertSeverityService.ts +63 -0
  57. package/Server/Services/DashboardService.ts +9 -10
  58. package/Server/Services/DatabaseService.ts +32 -2
  59. package/Server/Services/IncidentEpisodeStateTimelineService.ts +29 -0
  60. package/Server/Services/IncidentSeverityService.ts +76 -0
  61. package/Server/Services/Index.ts +12 -4
  62. package/Server/Services/InventoryItemCustomFieldService.ts +9 -0
  63. package/Server/Services/{TelemetryEntityRelationshipService.ts → InventoryItemRelationshipService.ts} +7 -4
  64. package/Server/Services/{TelemetryEntityService.ts → InventoryItemService.ts} +203 -21
  65. package/Server/Services/LogAggregationService.ts +45 -8
  66. package/Server/Services/MetricAggregationService.ts +121 -0
  67. package/Server/Services/MetricService.ts +7 -7
  68. package/Server/Services/NetworkDeviceLinkRuleService.ts +10 -0
  69. package/Server/Services/NetworkDeviceLinkService.ts +84 -0
  70. package/Server/Services/NetworkDeviceService.ts +140 -0
  71. package/Server/Services/NetworkSiteService.ts +77 -25
  72. package/Server/Services/NetworkTopologySuppressionService.ts +84 -0
  73. package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +41 -29
  74. package/Server/Services/OnCallDutyPolicyExecutionLogService.ts +8 -0
  75. package/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.ts +62 -13
  76. package/Server/Services/OnCallDutyPolicyScheduleService.ts +61 -1
  77. package/Server/Services/OnCallNotificationAlertingService.ts +742 -0
  78. package/Server/Services/OnCallReadinessService.ts +2803 -0
  79. package/Server/Services/OnCallSetupReminderService.ts +955 -0
  80. package/Server/Services/ProfileAggregationService.ts +123 -0
  81. package/Server/Services/StatusPageService.ts +9 -10
  82. package/Server/Services/TeamComplianceService.ts +429 -252
  83. package/Server/Services/UserCallService.ts +26 -1
  84. package/Server/Services/UserEmailService.ts +26 -1
  85. package/Server/Services/UserNotificationRuleAdminService.ts +1183 -0
  86. package/Server/Services/UserNotificationRuleService.ts +3812 -333
  87. package/Server/Services/UserOnCallLogService.ts +561 -48
  88. package/Server/Services/UserPushService.ts +29 -0
  89. package/Server/Services/UserService.ts +11 -0
  90. package/Server/Services/UserSmsService.ts +26 -1
  91. package/Server/Services/UserTelegramService.ts +24 -1
  92. package/Server/Services/UserWebhookService.ts +28 -1
  93. package/Server/Services/UserWhatsAppService.ts +24 -1
  94. package/Server/Types/Database/Permissions/BasePermission.ts +19 -0
  95. package/Server/Types/Database/Permissions/CreatePermission.ts +164 -0
  96. package/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.ts +340 -0
  97. package/Server/Types/Database/Permissions/QueryPermission.ts +48 -0
  98. package/Server/Types/Database/Permissions/TenantPermission.ts +8 -1
  99. package/Server/Types/Workflow/Components/API/Delete.ts +1 -1
  100. package/Server/Types/Workflow/Components/API/Get.ts +1 -1
  101. package/Server/Types/Workflow/Components/API/Patch.ts +1 -1
  102. package/Server/Types/Workflow/Components/API/Post.ts +1 -1
  103. package/Server/Types/Workflow/Components/API/Put.ts +1 -1
  104. package/Server/Types/Workflow/Components/API/Utils.ts +44 -1
  105. package/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.ts +29 -5
  106. package/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.ts +18 -10
  107. package/Server/Types/Workflow/Components/BaseModel/ModelArguments.ts +55 -0
  108. package/Server/Types/Workflow/Components/Conditions/IfElse.ts +3 -17
  109. package/Server/Types/Workflow/Components/Email.ts +25 -7
  110. package/Server/Types/Workflow/Components/JavaScript.ts +10 -3
  111. package/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.ts +1 -1
  112. package/Server/Types/Workflow/TriggerCode.ts +12 -0
  113. package/Server/Types/Workflow/Workflow.ts +5 -0
  114. package/Server/Utils/AI/Chat/ChatAgentRunner.ts +643 -48
  115. package/Server/Utils/AI/Chat/ObservabilityAssistant.ts +32 -3
  116. package/Server/Utils/AI/Chat/ObservabilityChatPrompt.ts +20 -6
  117. package/Server/Utils/AI/SRE/AIInvestigationEngine.ts +7 -0
  118. package/Server/Utils/AI/Toolbox/AIActionTools.ts +2 -2
  119. package/Server/Utils/AI/Toolbox/AIMetaTools.ts +863 -0
  120. package/Server/Utils/AI/Toolbox/AlertTools.ts +177 -15
  121. package/Server/Utils/AI/Toolbox/IncidentTools.ts +191 -10
  122. package/Server/Utils/AI/Toolbox/Index.ts +48 -0
  123. package/Server/Utils/AI/Toolbox/MonitorTools.ts +298 -11
  124. package/Server/Utils/AI/Toolbox/NoteWriteTools.ts +295 -0
  125. package/Server/Utils/AI/Toolbox/OnCallTools.ts +1246 -0
  126. package/Server/Utils/AI/Toolbox/RunbookTools.ts +424 -0
  127. package/Server/Utils/AI/Toolbox/SloTools.ts +456 -0
  128. package/Server/Utils/AI/Toolbox/StatusPageTools.ts +559 -0
  129. package/Server/Utils/AI/Toolbox/TeamTools.ts +327 -0
  130. package/Server/Utils/AI/Toolbox/TimelineTools.ts +615 -0
  131. package/Server/Utils/AI/Toolbox/WorkflowProbeTools.ts +664 -0
  132. package/Server/Utils/ClientIp.ts +221 -0
  133. package/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.ts +47 -0
  134. package/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.ts +163 -0
  135. package/Server/Utils/Dashboard/PublicDashboardSloWidget.ts +147 -0
  136. package/Server/Utils/Express.ts +12 -17
  137. package/Server/Utils/LLM/LLMService.ts +85 -8
  138. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +204 -10
  139. package/Server/Utils/SSRFProtection.ts +98 -23
  140. package/Server/Utils/StartServer.ts +12 -3
  141. package/Server/Utils/Telemetry/EntityRegistry.ts +205 -18
  142. package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +689 -0
  143. package/Server/Utils/Telemetry/TelemetryEntity.ts +160 -52
  144. package/Server/Utils/VM/VMAPI.ts +56 -8
  145. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +7 -3
  146. package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +2146 -0
  147. package/Tests/App/Dashboard/CreateWorkflowModal.test.tsx +561 -0
  148. package/Tests/App/Dashboard/EscalationRuleReadiness.test.tsx +2470 -0
  149. package/Tests/App/Dashboard/MonitorCriteriaAttributeFilter.test.tsx +574 -0
  150. package/Tests/App/Dashboard/OnCallPreventionGuards.test.tsx +1897 -0
  151. package/Tests/App/Dashboard/OnCallReadinessSurfaces.test.tsx +3606 -0
  152. package/Tests/App/Dashboard/OnCallRulesDeleteGuard.test.tsx +784 -0
  153. package/Tests/App/Dashboard/OnCallRulesTable.test.tsx +1119 -0
  154. package/Tests/App/Dashboard/SloWidgetFetching.test.tsx +531 -0
  155. package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +1312 -0
  156. package/Tests/App/Dashboard/UserSettingsSetupChecklistPage.test.tsx +1390 -0
  157. package/Tests/Models/InventoryItemModel.test.ts +174 -0
  158. package/Tests/Models/InventoryItemNaming.test.ts +302 -0
  159. package/Tests/Server/API/AIChatCancelAndFeedback.test.ts +437 -0
  160. package/Tests/Server/API/DashboardPublicRateLimit.test.ts +659 -0
  161. package/Tests/Server/API/DashboardPublicResourceListAPI.test.ts +18 -0
  162. package/Tests/Server/API/DashboardPublicSloAPI.test.ts +880 -0
  163. package/Tests/Server/API/Helpers.ts +24 -15
  164. package/Tests/Server/API/OnCallReadinessAPI.test.ts +2680 -0
  165. package/Tests/Server/API/OnCallSetupReminderAPI.test.ts +915 -0
  166. package/Tests/Server/API/UserProjectsAPI.test.ts +478 -4
  167. package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +533 -0
  168. package/Tests/Server/Infrastructure/Postgres/InventoryItemArchiveMigration.test.ts +213 -0
  169. package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +432 -0
  170. package/Tests/Server/Infrastructure/Queue.test.ts +293 -0
  171. package/Tests/Server/Middleware/PublicDashboardRateLimit.test.ts +1645 -0
  172. package/Tests/Server/Services/AdminRuleEditGuards.test.ts +2848 -0
  173. package/Tests/Server/Services/DeliverNotificationForRuleExtraction.test.ts +1393 -0
  174. package/Tests/Server/Services/EpisodeRuleSeverityRepair.test.ts +1802 -0
  175. package/Tests/Server/Services/EpisodeStateTimelineNote.test.ts +304 -0
  176. package/Tests/Server/Services/InventoryItemDisplayName.test.ts +339 -0
  177. package/Tests/Server/Services/InventoryItemManualCreate.test.ts +248 -0
  178. package/Tests/Server/Services/IpAllowlistSpoofing.test.ts +450 -0
  179. package/Tests/Server/Services/LogAggregationService.test.ts +235 -1
  180. package/Tests/Server/Services/MetricAggregationService.test.ts +231 -0
  181. package/Tests/Server/Services/MetricEntityMVKeyParity.test.ts +80 -29
  182. package/Tests/Server/Services/MetricServiceAggregate.test.ts +30 -30
  183. package/Tests/Server/Services/NetworkSiteService.test.ts +18 -3
  184. package/Tests/Server/Services/NotificationChannelEventCoverage.test.ts +1728 -0
  185. package/Tests/Server/Services/NotificationDeletionImpact.test.ts +2402 -0
  186. package/Tests/Server/Services/OnCallDutyPolicyExecutionLogTimelineGapFeed.test.ts +394 -0
  187. package/Tests/Server/Services/OnCallNotificationFallback.test.ts +1744 -0
  188. package/Tests/Server/Services/OnCallReadinessService.test.ts +4295 -0
  189. package/Tests/Server/Services/OnCallSetupReminder.test.ts +1272 -0
  190. package/Tests/Server/Services/OnCallWeeklyReadinessDigest.test.ts +1021 -0
  191. package/Tests/Server/Services/ProfileAggregationService.test.ts +296 -0
  192. package/Tests/Server/Services/SeverityCreationRuleBackfill.test.ts +1536 -0
  193. package/Tests/Server/Services/SeverityRuleBackfill.test.ts +1818 -0
  194. package/Tests/Server/Services/TeamComplianceServiceBehaviour.test.ts +1845 -0
  195. package/Tests/Server/Services/UserNotificationRuleAdminGuards.test.ts +1394 -0
  196. package/Tests/Server/Services/UserNotificationRuleDefaultCreation.test.ts +1166 -0
  197. package/Tests/Server/Services/UserNotificationRuleExecuteItem.test.ts +1468 -0
  198. package/Tests/Server/Services/UserOnCallLogNoNotificationRules.test.ts +1457 -0
  199. package/Tests/Server/Types/Database/Permissions/AdminNotificationRuleAccess.test.ts +1546 -0
  200. package/Tests/Server/Types/Database/Permissions/CreateOwnershipScoping.test.ts +529 -0
  201. package/Tests/Server/Types/Database/Permissions/OwnerOnlyColumns.test.ts +1219 -0
  202. package/Tests/Server/Types/Database/Permissions/UserNotificationRuleScoping.test.ts +1089 -0
  203. package/Tests/Server/Types/Workflow/Components/ApiComponentErrorPort.test.ts +2 -1
  204. package/Tests/Server/Types/Workflow/Components/ApiComponentHeaders.test.ts +192 -0
  205. package/Tests/Server/Types/Workflow/Components/BaseModelDatabaseComponents.test.ts +190 -0
  206. package/Tests/Server/Types/Workflow/Components/ChatWebhookComponents.test.ts +44 -14
  207. package/Tests/Server/Types/Workflow/Components/Email.test.ts +151 -0
  208. package/Tests/Server/Types/Workflow/Components/IfElse.test.ts +98 -0
  209. package/Tests/Server/Types/Workflow/Components/JavaScript.test.ts +51 -0
  210. package/Tests/Server/Utils/AI/AIMetaTools.test.ts +586 -0
  211. package/Tests/Server/Utils/AI/AlertMonitorFilters.test.ts +582 -0
  212. package/Tests/Server/Utils/AI/ChatAgentRunner.test.ts +726 -0
  213. package/Tests/Server/Utils/AI/IncidentToolsFilters.test.ts +315 -0
  214. package/Tests/Server/Utils/AI/LLMServiceStopReason.test.ts +314 -0
  215. package/Tests/Server/Utils/AI/LLMServiceToolCalling.test.ts +26 -3
  216. package/Tests/Server/Utils/AI/NoteWriteTools.test.ts +268 -0
  217. package/Tests/Server/Utils/AI/ObservabilityChatPrompt.test.ts +169 -0
  218. package/Tests/Server/Utils/AI/OnCallTools.test.ts +664 -0
  219. package/Tests/Server/Utils/AI/RunbookTools.test.ts +325 -0
  220. package/Tests/Server/Utils/AI/SloTools.test.ts +306 -0
  221. package/Tests/Server/Utils/AI/StatusPageTools.test.ts +391 -0
  222. package/Tests/Server/Utils/AI/TeamTools.test.ts +257 -0
  223. package/Tests/Server/Utils/AI/TimelineTools.test.ts +472 -0
  224. package/Tests/Server/Utils/AI/WorkflowProbeTools.test.ts +428 -0
  225. package/Tests/Server/Utils/AnalyticsDatabase/QuerySettingsHelper.test.ts +152 -0
  226. package/Tests/Server/Utils/ClientIp.test.ts +438 -0
  227. package/Tests/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.test.ts +171 -0
  228. package/Tests/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.test.ts +383 -0
  229. package/Tests/Server/Utils/EntityRegistryRowFence.test.ts +23 -25
  230. package/Tests/Server/Utils/MicrosoftTeamsWebhookUrlValidation.test.ts +6 -0
  231. package/Tests/Server/Utils/Monitor/Criteria/DnssecMonitorCriteria.test.ts +307 -0
  232. package/Tests/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.test.ts +468 -0
  233. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorTelemetryDeepLinks.test.ts +460 -0
  234. package/Tests/Server/Utils/ResponseRateLimitStatusCodes.test.ts +137 -0
  235. package/Tests/Server/Utils/SSRFProtectionBypasses.test.ts +40 -8
  236. package/Tests/Server/Utils/SSRFProtectionUserInfo.test.ts +351 -0
  237. package/Tests/Server/Utils/Telemetry/EntityRegistryRetirement.test.ts +531 -0
  238. package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +322 -0
  239. package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +356 -39
  240. package/Tests/Server/Utils/VM/VMAPISubstitution.test.ts +243 -0
  241. package/Tests/Types/IP/IP.test.ts +263 -0
  242. package/Tests/Types/IP/IPWhitelist.test.ts +197 -0
  243. package/Tests/Types/IP/IPv6.test.ts +12 -1
  244. package/Tests/Types/Monitor/SnmpOid.test.ts +64 -0
  245. package/Tests/Types/NetworkDevice/NetworkDeviceMonitoringMethod.test.ts +110 -0
  246. package/Tests/Types/OnCallDutyPolicy/LayerUtilMergeAudit.test.ts +106 -0
  247. package/Tests/Types/OnCallDutyPolicy/LayerUtilMergeDifferential.test.ts +511 -0
  248. package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageEndToEnd.test.ts +489 -0
  249. package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageGapTolerance.test.ts +479 -0
  250. package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageState.test.ts +822 -0
  251. package/Tests/Types/SerializableObjectDictionaryImportCycle.test.ts +197 -0
  252. package/Tests/Types/Telemetry/EntityTypeGroups.test.ts +140 -0
  253. package/Tests/Types/Workflow/BaseModelComponents.test.ts +429 -0
  254. package/Tests/Types/Workflow/Components/BaseModel.test.ts +225 -0
  255. package/Tests/Types/Workflow/IntegrationCredentialMetadata.test.ts +100 -0
  256. package/Tests/Types/Workflow/StepTrace.test.ts +235 -0
  257. package/Tests/Types/Workflow/TemplateSyntax.test.ts +491 -0
  258. package/Tests/Types/Workflow/Templates.test.ts +1218 -0
  259. package/Tests/UI/Components/ActiveFilterChipsOpenRoute.test.tsx +82 -0
  260. package/Tests/UI/Components/ComponentsModal.test.tsx +564 -7
  261. package/Tests/UI/Components/CustomTimeRangeModal.test.tsx +459 -0
  262. package/Tests/UI/Components/DictionaryAttributeFilterRow.test.tsx +477 -0
  263. package/Tests/UI/Components/Forms/AnchoredFieldPopupKeyboard.test.tsx +382 -0
  264. package/Tests/UI/Components/Forms/ColorPickerPicking.test.tsx +569 -0
  265. package/Tests/UI/Components/Forms/ValidationJSON.test.ts +241 -0
  266. package/Tests/UI/Components/KeyboardShortcut.test.tsx +95 -0
  267. package/Tests/UI/Components/LogDetailsPanelCrossSignal.test.tsx +448 -0
  268. package/Tests/UI/Components/LogTimeRangePicker.test.tsx +42 -0
  269. package/Tests/UI/Components/LogsTableCrossLinks.test.tsx +263 -0
  270. package/Tests/UI/Components/ModalPortalDismissal.test.tsx +90 -0
  271. package/Tests/UI/Components/PendingProjectInvitations.test.tsx +913 -0
  272. package/Tests/UI/Components/SimpleLogViewer.test.tsx +228 -0
  273. package/Tests/UI/Components/TableRowSelectability.test.tsx +312 -0
  274. package/Tests/UI/Components/TelemetryTimeRangePicker.test.tsx +49 -7
  275. package/Tests/UI/Components/TimeRangePickerDropdown.test.tsx +325 -0
  276. package/Tests/UI/Components/Workflow/GraphLint.test.ts +893 -0
  277. package/Tests/UI/Components/Workflow/GraphLintSummary.test.ts +755 -0
  278. package/Tests/UI/Components/Workflow/ModelColumnEditor.test.ts +522 -0
  279. package/Tests/UI/Components/Workflow/ModelColumnEditorServerContract.test.ts +193 -0
  280. package/Tests/UI/Components/Workflow/ModelSchema.test.ts +388 -0
  281. package/Tests/UI/Components/Workflow/RunStatusWatcher.test.ts +210 -0
  282. package/Tests/UI/Components/Workflow/StepTraceViewer.test.tsx +256 -0
  283. package/Tests/UI/Components/Workflow/UseRunWatch.test.tsx +665 -0
  284. package/Tests/UI/Components/Workflow/Utils.test.ts +192 -0
  285. package/Tests/UI/Components/Workflow/WorkflowIssuesModal.test.tsx +485 -0
  286. package/Tests/UI/Components/Workflow/WorkflowLogModal.test.tsx +478 -0
  287. package/Tests/UI/Components/Workflow/WorkflowStatusBar.test.tsx +379 -0
  288. package/Tests/UI/EsbuildConfig.test.ts +607 -0
  289. package/Tests/UI/Monitor/MonitorStepCriteriaView.test.tsx +432 -0
  290. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +5 -0
  291. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +11 -4
  292. package/Tests/UI/Utils/ModelAPICreateMiscData.test.ts +94 -0
  293. package/Tests/UI/Utils/Platform.test.ts +147 -0
  294. package/Tests/UI/Utils/ProjectInvitationDisplay.test.ts +357 -0
  295. package/Tests/Utils/Monitor/NetworkDeviceLinkRuleUtil.test.ts +198 -0
  296. package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +514 -0
  297. package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +727 -0
  298. package/Tests/Utils/Schema/AnalyticsModelSchema.test.ts +469 -0
  299. package/Tests/Utils/Schema/ModelSchema.test.ts +268 -0
  300. package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +698 -0
  301. package/Tests/Utils/Telemetry/EntityKeyNonTelemetry.test.ts +193 -0
  302. package/Tests/__mocks__/bullmq.js +55 -0
  303. package/Types/AI/AIChatMessageStatus.ts +8 -1
  304. package/Types/AI/AIChatTypes.ts +12 -0
  305. package/Types/Database/AccessControl/OwnerOnlyColumn.ts +88 -0
  306. package/Types/Exception/ExceptionCode.ts +2 -0
  307. package/Types/Exception/ServiceUnavailableException.ts +8 -0
  308. package/Types/Exception/TooManyRequestsException.ts +8 -0
  309. package/Types/IP/IP.ts +93 -47
  310. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +80 -3
  311. package/Types/NetworkDevice/NetworkDeviceMonitoringMethod.ts +55 -0
  312. package/Types/OnCallDutyPolicy/Layer.ts +203 -149
  313. package/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.ts +13 -0
  314. package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +155 -10
  315. package/Types/Permission.ts +193 -0
  316. package/Types/SerializableObjectDictionary.ts +133 -39
  317. package/Types/Telemetry/EntityRelationshipType.ts +1 -1
  318. package/Types/Telemetry/EntitySource.ts +40 -0
  319. package/Types/Telemetry/EntityType.ts +28 -1
  320. package/Types/Telemetry/EntityTypeGroups.ts +65 -0
  321. package/Types/Workflow/Component.ts +29 -0
  322. package/Types/Workflow/Components/API.ts +35 -0
  323. package/Types/Workflow/Components/BaseModel.ts +77 -27
  324. package/Types/Workflow/Components/Discord.ts +1 -0
  325. package/Types/Workflow/Components/Email.ts +12 -3
  326. package/Types/Workflow/Components/JavaScript.ts +7 -0
  327. package/Types/Workflow/Components/MicrosoftTeams.ts +3 -2
  328. package/Types/Workflow/Components/Slack.ts +1 -0
  329. package/Types/Workflow/Components/Telegram.ts +1 -0
  330. package/Types/Workflow/StepTrace.ts +181 -0
  331. package/Types/Workflow/TemplateSyntax.ts +543 -0
  332. package/Types/Workflow/Templates.ts +2368 -0
  333. package/UI/Components/Calendar/Calendar.css +43 -0
  334. package/UI/Components/Calendar/Calendar.tsx +8 -0
  335. package/UI/Components/Card/Card.tsx +2 -2
  336. package/UI/Components/Checkbox/Checkbox.tsx +16 -0
  337. package/UI/Components/Date/CustomTimeRangeModal.tsx +278 -0
  338. package/UI/Components/Date/TimeRangePickerDropdown.tsx +250 -0
  339. package/UI/Components/Dictionary/Dictionary.tsx +71 -15
  340. package/UI/Components/FormModal/BasicFormModal.tsx +2 -1
  341. package/UI/Components/Forms/Fields/ColorPicker.tsx +66 -10
  342. package/UI/Components/Forms/Fields/IconPicker.tsx +48 -10
  343. package/UI/Components/Forms/Types/Field.ts +7 -0
  344. package/UI/Components/Forms/Validation.ts +63 -1
  345. package/UI/Components/Header/HeaderIconDropdownButton.tsx +53 -3
  346. package/UI/Components/Input/Input.tsx +32 -3
  347. package/UI/Components/KeyboardShortcut/KeyboardKey.ts +185 -0
  348. package/UI/Components/KeyboardShortcut/KeyboardShortcut.tsx +87 -0
  349. package/UI/Components/LogsViewer/LogsViewer.tsx +28 -0
  350. package/UI/Components/LogsViewer/components/ActiveFilterChips.tsx +31 -0
  351. package/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.tsx +18 -18
  352. package/UI/Components/LogsViewer/components/LogDetailsPanel.tsx +363 -14
  353. package/UI/Components/LogsViewer/components/LogTimeRangePicker.tsx +11 -216
  354. package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +11 -0
  355. package/UI/Components/LogsViewer/components/LogsTable.tsx +155 -12
  356. package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +29 -0
  357. package/UI/Components/LogsViewer/types.ts +23 -0
  358. package/UI/Components/Markdown.tsx/MarkdownEditor.tsx +9 -2
  359. package/UI/Components/Modal/Modal.tsx +37 -4
  360. package/UI/Components/Navbar/NavBarMenuModal.tsx +15 -30
  361. package/UI/Components/ProjectInvitations/PendingProjectInvitations.tsx +442 -0
  362. package/UI/Components/SimpleLogViewer/SimpleLogViewer.tsx +23 -1
  363. package/UI/Components/Table/Table.tsx +49 -16
  364. package/UI/Components/Table/TableBody.tsx +53 -28
  365. package/UI/Components/Table/TableHeader.tsx +13 -0
  366. package/UI/Components/Table/TableRow.tsx +58 -26
  367. package/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.tsx +11 -210
  368. package/UI/Components/Workflow/ArgumentsForm.tsx +341 -8
  369. package/UI/Components/Workflow/Component.tsx +28 -26
  370. package/UI/Components/Workflow/ComponentReturnValueViewer.tsx +26 -0
  371. package/UI/Components/Workflow/ComponentSettingsModal.tsx +79 -9
  372. package/UI/Components/Workflow/ComponentValuePickerModal.tsx +135 -7
  373. package/UI/Components/Workflow/ComponentsModal.tsx +116 -22
  374. package/UI/Components/Workflow/DocumentationViewer.tsx +59 -9
  375. package/UI/Components/Workflow/GraphLint.ts +628 -0
  376. package/UI/Components/Workflow/GraphLintSummary.ts +390 -0
  377. package/UI/Components/Workflow/ModelColumnEditor.tsx +528 -0
  378. package/UI/Components/Workflow/ModelSchema.ts +278 -0
  379. package/UI/Components/Workflow/RunForm.tsx +41 -7
  380. package/UI/Components/Workflow/RunStatusWatcher.ts +122 -0
  381. package/UI/Components/Workflow/StepTraceViewer.tsx +186 -0
  382. package/UI/Components/Workflow/UseRunWatch.ts +212 -0
  383. package/UI/Components/Workflow/Utils.ts +93 -1
  384. package/UI/Components/Workflow/VariableModal.tsx +6 -2
  385. package/UI/Components/Workflow/Workflow.tsx +126 -7
  386. package/UI/Components/Workflow/WorkflowIssuesModal.tsx +255 -0
  387. package/UI/Components/Workflow/WorkflowLogModal.tsx +128 -0
  388. package/UI/Components/Workflow/WorkflowStatusBar.tsx +224 -0
  389. package/UI/Types/LayeredDismissal.ts +31 -0
  390. package/UI/Types/UseAnchoredFieldPopup.ts +99 -0
  391. package/UI/Utils/AIChatExport/ConversationMarkdown.ts +10 -0
  392. package/UI/Utils/ModelAPI/ModelAPI.ts +7 -1
  393. package/UI/Utils/Platform.ts +149 -0
  394. package/UI/Utils/ProjectInvitationDisplay.ts +118 -0
  395. package/UI/esbuild-config.js +22 -1
  396. package/Utils/Monitor/NetworkDeviceLinkRuleUtil.ts +187 -0
  397. package/Utils/Monitor/NetworkDeviceRoleUtil.ts +533 -0
  398. package/Utils/Monitor/NetworkTopologyUtil.ts +917 -155
  399. package/Utils/Telemetry/CrossSignalScope.ts +502 -0
  400. package/Utils/Telemetry/EntityKey.ts +71 -5
  401. package/Utils/Telemetry/EntityRelationship.ts +1 -1
  402. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.js +1 -1
  403. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByService.js +1 -1
  404. package/build/dist/Models/DatabaseModels/AIConversation.js +32 -0
  405. package/build/dist/Models/DatabaseModels/AIConversation.js.map +1 -1
  406. package/build/dist/Models/DatabaseModels/AIConversationMessage.js +42 -0
  407. package/build/dist/Models/DatabaseModels/AIConversationMessage.js.map +1 -1
  408. package/build/dist/Models/DatabaseModels/AlertEpisodeMember.js +28 -0
  409. package/build/dist/Models/DatabaseModels/AlertEpisodeMember.js.map +1 -1
  410. package/build/dist/Models/DatabaseModels/IncidentEpisodeMember.js +29 -0
  411. package/build/dist/Models/DatabaseModels/IncidentEpisodeMember.js.map +1 -1
  412. package/build/dist/Models/DatabaseModels/Index.js +12 -4
  413. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  414. package/build/dist/Models/DatabaseModels/{TelemetryEntity.js → InventoryItem.js} +212 -29
  415. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -0
  416. package/build/dist/Models/DatabaseModels/InventoryItemCustomField.js +454 -0
  417. package/build/dist/Models/DatabaseModels/InventoryItemCustomField.js.map +1 -0
  418. package/build/dist/Models/DatabaseModels/{TelemetryEntityRelationship.js → InventoryItemRelationship.js} +52 -25
  419. package/build/dist/Models/DatabaseModels/InventoryItemRelationship.js.map +1 -0
  420. package/build/dist/Models/DatabaseModels/NetworkDevice.js +141 -0
  421. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  422. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +719 -0
  423. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -0
  424. package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js +475 -0
  425. package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js.map +1 -0
  426. package/build/dist/Models/DatabaseModels/NetworkTopologySuppression.js +446 -0
  427. package/build/dist/Models/DatabaseModels/NetworkTopologySuppression.js.map +1 -0
  428. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyFeed.js +9 -0
  429. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyFeed.js.map +1 -1
  430. package/build/dist/Models/DatabaseModels/Project.js +80 -0
  431. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  432. package/build/dist/Models/DatabaseModels/UserCall.js +46 -2
  433. package/build/dist/Models/DatabaseModels/UserCall.js.map +1 -1
  434. package/build/dist/Models/DatabaseModels/UserEmail.js +48 -2
  435. package/build/dist/Models/DatabaseModels/UserEmail.js.map +1 -1
  436. package/build/dist/Models/DatabaseModels/UserNotificationRule.js +424 -55
  437. package/build/dist/Models/DatabaseModels/UserNotificationRule.js.map +1 -1
  438. package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js +24 -2
  439. package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js.map +1 -1
  440. package/build/dist/Models/DatabaseModels/UserPush.js +51 -1
  441. package/build/dist/Models/DatabaseModels/UserPush.js.map +1 -1
  442. package/build/dist/Models/DatabaseModels/UserSMS.js +47 -2
  443. package/build/dist/Models/DatabaseModels/UserSMS.js.map +1 -1
  444. package/build/dist/Models/DatabaseModels/UserTelegram.js +65 -3
  445. package/build/dist/Models/DatabaseModels/UserTelegram.js.map +1 -1
  446. package/build/dist/Models/DatabaseModels/UserWebhook.js +56 -2
  447. package/build/dist/Models/DatabaseModels/UserWebhook.js.map +1 -1
  448. package/build/dist/Models/DatabaseModels/UserWhatsApp.js +45 -2
  449. package/build/dist/Models/DatabaseModels/UserWhatsApp.js.map +1 -1
  450. package/build/dist/Models/DatabaseModels/WorkflowLog.js +39 -0
  451. package/build/dist/Models/DatabaseModels/WorkflowLog.js.map +1 -1
  452. package/build/dist/Models/DatabaseModels/WorkflowVariable.js +12 -0
  453. package/build/dist/Models/DatabaseModels/WorkflowVariable.js.map +1 -1
  454. package/build/dist/Server/API/AIChatAPI.js +237 -1
  455. package/build/dist/Server/API/AIChatAPI.js.map +1 -1
  456. package/build/dist/Server/API/DashboardAPI.js +165 -13
  457. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  458. package/build/dist/Server/API/OnCallReadinessAPI.js +599 -0
  459. package/build/dist/Server/API/OnCallReadinessAPI.js.map +1 -0
  460. package/build/dist/Server/API/TeamComplianceAPI.js +68 -9
  461. package/build/dist/Server/API/TeamComplianceAPI.js.map +1 -1
  462. package/build/dist/Server/API/TelemetryAPI.js +129 -18
  463. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  464. package/build/dist/Server/API/UserAPI.js +16 -1
  465. package/build/dist/Server/API/UserAPI.js.map +1 -1
  466. package/build/dist/Server/EnvironmentConfig.js +45 -0
  467. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  468. package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js +22 -0
  469. package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js.map +1 -1
  470. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js +4 -4
  471. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.js +20 -0
  472. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.js.map +1 -0
  473. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.js +12 -0
  474. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.js.map +1 -0
  475. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.js +18 -0
  476. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.js.map +1 -0
  477. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.js +39 -0
  478. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.js.map +1 -0
  479. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.js +38 -0
  480. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.js.map +1 -0
  481. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.js +22 -0
  482. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.js.map +1 -0
  483. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.js +150 -0
  484. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.js.map +1 -0
  485. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.js +57 -0
  486. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.js.map +1 -0
  487. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.js +69 -0
  488. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.js.map +1 -0
  489. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.js +32 -0
  490. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.js.map +1 -0
  491. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.js +26 -0
  492. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.js.map +1 -0
  493. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.js +48 -0
  494. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.js.map +1 -0
  495. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +24 -0
  496. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  497. package/build/dist/Server/Infrastructure/Queue.js +72 -13
  498. package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
  499. package/build/dist/Server/Middleware/MasterAdminAuthorization.js +11 -6
  500. package/build/dist/Server/Middleware/MasterAdminAuthorization.js.map +1 -1
  501. package/build/dist/Server/Middleware/PublicDashboardRateLimit.js +399 -0
  502. package/build/dist/Server/Middleware/PublicDashboardRateLimit.js.map +1 -0
  503. package/build/dist/Server/Services/AIService.js +1 -0
  504. package/build/dist/Server/Services/AIService.js.map +1 -1
  505. package/build/dist/Server/Services/AlertEpisodeStateTimelineService.js +24 -3
  506. package/build/dist/Server/Services/AlertEpisodeStateTimelineService.js.map +1 -1
  507. package/build/dist/Server/Services/AlertSeverityService.js +54 -0
  508. package/build/dist/Server/Services/AlertSeverityService.js.map +1 -1
  509. package/build/dist/Server/Services/DashboardService.js +10 -9
  510. package/build/dist/Server/Services/DashboardService.js.map +1 -1
  511. package/build/dist/Server/Services/DatabaseService.js +24 -2
  512. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  513. package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js +24 -3
  514. package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js.map +1 -1
  515. package/build/dist/Server/Services/IncidentSeverityService.js +67 -0
  516. package/build/dist/Server/Services/IncidentSeverityService.js.map +1 -1
  517. package/build/dist/Server/Services/Index.js +12 -4
  518. package/build/dist/Server/Services/Index.js.map +1 -1
  519. package/build/dist/Server/Services/InventoryItemCustomFieldService.js +9 -0
  520. package/build/dist/Server/Services/InventoryItemCustomFieldService.js.map +1 -0
  521. package/build/dist/Server/Services/{TelemetryEntityRelationshipService.js → InventoryItemRelationshipService.js} +9 -6
  522. package/build/dist/Server/Services/InventoryItemRelationshipService.js.map +1 -0
  523. package/build/dist/Server/Services/{TelemetryEntityService.js → InventoryItemService.js} +158 -23
  524. package/build/dist/Server/Services/InventoryItemService.js.map +1 -0
  525. package/build/dist/Server/Services/LogAggregationService.js +27 -8
  526. package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
  527. package/build/dist/Server/Services/MetricAggregationService.js +80 -0
  528. package/build/dist/Server/Services/MetricAggregationService.js.map +1 -1
  529. package/build/dist/Server/Services/MetricService.js +6 -6
  530. package/build/dist/Server/Services/MetricService.js.map +1 -1
  531. package/build/dist/Server/Services/NetworkDeviceLinkRuleService.js +9 -0
  532. package/build/dist/Server/Services/NetworkDeviceLinkRuleService.js.map +1 -0
  533. package/build/dist/Server/Services/NetworkDeviceLinkService.js +71 -0
  534. package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -0
  535. package/build/dist/Server/Services/NetworkDeviceService.js +113 -0
  536. package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
  537. package/build/dist/Server/Services/NetworkSiteService.js +53 -13
  538. package/build/dist/Server/Services/NetworkSiteService.js.map +1 -1
  539. package/build/dist/Server/Services/NetworkTopologySuppressionService.js +85 -0
  540. package/build/dist/Server/Services/NetworkTopologySuppressionService.js.map +1 -0
  541. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +48 -32
  542. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
  543. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js +8 -0
  544. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js.map +1 -1
  545. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.js +51 -12
  546. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.js.map +1 -1
  547. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +57 -13
  548. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
  549. package/build/dist/Server/Services/OnCallNotificationAlertingService.js +548 -0
  550. package/build/dist/Server/Services/OnCallNotificationAlertingService.js.map +1 -0
  551. package/build/dist/Server/Services/OnCallReadinessService.js +1961 -0
  552. package/build/dist/Server/Services/OnCallReadinessService.js.map +1 -0
  553. package/build/dist/Server/Services/OnCallSetupReminderService.js +738 -0
  554. package/build/dist/Server/Services/OnCallSetupReminderService.js.map +1 -0
  555. package/build/dist/Server/Services/ProfileAggregationService.js +68 -4
  556. package/build/dist/Server/Services/ProfileAggregationService.js.map +1 -1
  557. package/build/dist/Server/Services/StatusPageService.js +11 -10
  558. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  559. package/build/dist/Server/Services/TeamComplianceService.js +312 -160
  560. package/build/dist/Server/Services/TeamComplianceService.js.map +1 -1
  561. package/build/dist/Server/Services/UserCallService.js +24 -1
  562. package/build/dist/Server/Services/UserCallService.js.map +1 -1
  563. package/build/dist/Server/Services/UserEmailService.js +24 -1
  564. package/build/dist/Server/Services/UserEmailService.js.map +1 -1
  565. package/build/dist/Server/Services/UserNotificationRuleAdminService.js +858 -0
  566. package/build/dist/Server/Services/UserNotificationRuleAdminService.js.map +1 -0
  567. package/build/dist/Server/Services/UserNotificationRuleService.js +2830 -175
  568. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  569. package/build/dist/Server/Services/UserOnCallLogService.js +488 -43
  570. package/build/dist/Server/Services/UserOnCallLogService.js.map +1 -1
  571. package/build/dist/Server/Services/UserPushService.js +26 -0
  572. package/build/dist/Server/Services/UserPushService.js.map +1 -1
  573. package/build/dist/Server/Services/UserService.js +10 -0
  574. package/build/dist/Server/Services/UserService.js.map +1 -1
  575. package/build/dist/Server/Services/UserSmsService.js +24 -1
  576. package/build/dist/Server/Services/UserSmsService.js.map +1 -1
  577. package/build/dist/Server/Services/UserTelegramService.js +22 -1
  578. package/build/dist/Server/Services/UserTelegramService.js.map +1 -1
  579. package/build/dist/Server/Services/UserWebhookService.js +25 -1
  580. package/build/dist/Server/Services/UserWebhookService.js.map +1 -1
  581. package/build/dist/Server/Services/UserWhatsAppService.js +22 -1
  582. package/build/dist/Server/Services/UserWhatsAppService.js.map +1 -1
  583. package/build/dist/Server/Types/Database/Permissions/BasePermission.js +12 -1
  584. package/build/dist/Server/Types/Database/Permissions/BasePermission.js.map +1 -1
  585. package/build/dist/Server/Types/Database/Permissions/CreatePermission.js +126 -0
  586. package/build/dist/Server/Types/Database/Permissions/CreatePermission.js.map +1 -1
  587. package/build/dist/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.js +254 -0
  588. package/build/dist/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.js.map +1 -0
  589. package/build/dist/Server/Types/Database/Permissions/QueryPermission.js +47 -2
  590. package/build/dist/Server/Types/Database/Permissions/QueryPermission.js.map +1 -1
  591. package/build/dist/Server/Types/Database/Permissions/TenantPermission.js +7 -0
  592. package/build/dist/Server/Types/Database/Permissions/TenantPermission.js.map +1 -1
  593. package/build/dist/Server/Types/Workflow/Components/API/Delete.js +1 -1
  594. package/build/dist/Server/Types/Workflow/Components/API/Delete.js.map +1 -1
  595. package/build/dist/Server/Types/Workflow/Components/API/Get.js +1 -1
  596. package/build/dist/Server/Types/Workflow/Components/API/Get.js.map +1 -1
  597. package/build/dist/Server/Types/Workflow/Components/API/Patch.js +1 -1
  598. package/build/dist/Server/Types/Workflow/Components/API/Patch.js.map +1 -1
  599. package/build/dist/Server/Types/Workflow/Components/API/Post.js +1 -1
  600. package/build/dist/Server/Types/Workflow/Components/API/Post.js.map +1 -1
  601. package/build/dist/Server/Types/Workflow/Components/API/Put.js +1 -1
  602. package/build/dist/Server/Types/Workflow/Components/API/Put.js.map +1 -1
  603. package/build/dist/Server/Types/Workflow/Components/API/Utils.js +28 -0
  604. package/build/dist/Server/Types/Workflow/Components/API/Utils.js.map +1 -1
  605. package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.js +21 -5
  606. package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.js.map +1 -1
  607. package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.js +14 -10
  608. package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.js.map +1 -1
  609. package/build/dist/Server/Types/Workflow/Components/BaseModel/ModelArguments.js +31 -0
  610. package/build/dist/Server/Types/Workflow/Components/BaseModel/ModelArguments.js.map +1 -1
  611. package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js +3 -9
  612. package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js.map +1 -1
  613. package/build/dist/Server/Types/Workflow/Components/Email.js +15 -4
  614. package/build/dist/Server/Types/Workflow/Components/Email.js.map +1 -1
  615. package/build/dist/Server/Types/Workflow/Components/JavaScript.js +7 -2
  616. package/build/dist/Server/Types/Workflow/Components/JavaScript.js.map +1 -1
  617. package/build/dist/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.js +1 -1
  618. package/build/dist/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.js.map +1 -1
  619. package/build/dist/Server/Types/Workflow/TriggerCode.js.map +1 -1
  620. package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js +514 -56
  621. package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js.map +1 -1
  622. package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js +20 -3
  623. package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js.map +1 -1
  624. package/build/dist/Server/Utils/AI/Chat/ObservabilityChatPrompt.js +19 -6
  625. package/build/dist/Server/Utils/AI/Chat/ObservabilityChatPrompt.js.map +1 -1
  626. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js +7 -0
  627. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js.map +1 -1
  628. package/build/dist/Server/Utils/AI/Toolbox/AIActionTools.js +2 -2
  629. package/build/dist/Server/Utils/AI/Toolbox/AIActionTools.js.map +1 -1
  630. package/build/dist/Server/Utils/AI/Toolbox/AIMetaTools.js +692 -0
  631. package/build/dist/Server/Utils/AI/Toolbox/AIMetaTools.js.map +1 -0
  632. package/build/dist/Server/Utils/AI/Toolbox/AlertTools.js +148 -12
  633. package/build/dist/Server/Utils/AI/Toolbox/AlertTools.js.map +1 -1
  634. package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js +157 -10
  635. package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js.map +1 -1
  636. package/build/dist/Server/Utils/AI/Toolbox/Index.js +37 -0
  637. package/build/dist/Server/Utils/AI/Toolbox/Index.js.map +1 -1
  638. package/build/dist/Server/Utils/AI/Toolbox/MonitorTools.js +259 -14
  639. package/build/dist/Server/Utils/AI/Toolbox/MonitorTools.js.map +1 -1
  640. package/build/dist/Server/Utils/AI/Toolbox/NoteWriteTools.js +235 -0
  641. package/build/dist/Server/Utils/AI/Toolbox/NoteWriteTools.js.map +1 -0
  642. package/build/dist/Server/Utils/AI/Toolbox/OnCallTools.js +1000 -0
  643. package/build/dist/Server/Utils/AI/Toolbox/OnCallTools.js.map +1 -0
  644. package/build/dist/Server/Utils/AI/Toolbox/RunbookTools.js +356 -0
  645. package/build/dist/Server/Utils/AI/Toolbox/RunbookTools.js.map +1 -0
  646. package/build/dist/Server/Utils/AI/Toolbox/SloTools.js +394 -0
  647. package/build/dist/Server/Utils/AI/Toolbox/SloTools.js.map +1 -0
  648. package/build/dist/Server/Utils/AI/Toolbox/StatusPageTools.js +465 -0
  649. package/build/dist/Server/Utils/AI/Toolbox/StatusPageTools.js.map +1 -0
  650. package/build/dist/Server/Utils/AI/Toolbox/TeamTools.js +280 -0
  651. package/build/dist/Server/Utils/AI/Toolbox/TeamTools.js.map +1 -0
  652. package/build/dist/Server/Utils/AI/Toolbox/TimelineTools.js +527 -0
  653. package/build/dist/Server/Utils/AI/Toolbox/TimelineTools.js.map +1 -0
  654. package/build/dist/Server/Utils/AI/Toolbox/WorkflowProbeTools.js +548 -0
  655. package/build/dist/Server/Utils/AI/Toolbox/WorkflowProbeTools.js.map +1 -0
  656. package/build/dist/Server/Utils/ClientIp.js +137 -0
  657. package/build/dist/Server/Utils/ClientIp.js.map +1 -0
  658. package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js +38 -0
  659. package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js.map +1 -1
  660. package/build/dist/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.js +89 -0
  661. package/build/dist/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.js.map +1 -0
  662. package/build/dist/Server/Utils/Dashboard/PublicDashboardSloWidget.js +77 -0
  663. package/build/dist/Server/Utils/Dashboard/PublicDashboardSloWidget.js.map +1 -0
  664. package/build/dist/Server/Utils/Express.js +12 -12
  665. package/build/dist/Server/Utils/Express.js.map +1 -1
  666. package/build/dist/Server/Utils/LLM/LLMService.js +70 -7
  667. package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
  668. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +121 -11
  669. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  670. package/build/dist/Server/Utils/SSRFProtection.js +82 -21
  671. package/build/dist/Server/Utils/SSRFProtection.js.map +1 -1
  672. package/build/dist/Server/Utils/StartServer.js +12 -4
  673. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  674. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +165 -18
  675. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
  676. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +507 -0
  677. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -0
  678. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +122 -47
  679. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
  680. package/build/dist/Server/Utils/VM/VMAPI.js +51 -4
  681. package/build/dist/Server/Utils/VM/VMAPI.js.map +1 -1
  682. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +7 -3
  683. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  684. package/build/dist/Types/AI/AIChatMessageStatus.js +8 -1
  685. package/build/dist/Types/AI/AIChatMessageStatus.js.map +1 -1
  686. package/build/dist/Types/AI/AIChatTypes.js +12 -0
  687. package/build/dist/Types/AI/AIChatTypes.js.map +1 -1
  688. package/build/dist/Types/Database/AccessControl/OwnerOnlyColumn.js +60 -0
  689. package/build/dist/Types/Database/AccessControl/OwnerOnlyColumn.js.map +1 -0
  690. package/build/dist/Types/Exception/ExceptionCode.js +2 -0
  691. package/build/dist/Types/Exception/ExceptionCode.js.map +1 -1
  692. package/build/dist/Types/Exception/ServiceUnavailableException.js +8 -0
  693. package/build/dist/Types/Exception/ServiceUnavailableException.js.map +1 -0
  694. package/build/dist/Types/Exception/TooManyRequestsException.js +8 -0
  695. package/build/dist/Types/Exception/TooManyRequestsException.js.map +1 -0
  696. package/build/dist/Types/IP/IP.js +87 -43
  697. package/build/dist/Types/IP/IP.js.map +1 -1
  698. package/build/dist/Types/NetworkDevice/NetworkDeviceMonitoringMethod.js +50 -0
  699. package/build/dist/Types/NetworkDevice/NetworkDeviceMonitoringMethod.js.map +1 -0
  700. package/build/dist/Types/OnCallDutyPolicy/Layer.js +186 -123
  701. package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
  702. package/build/dist/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.js +13 -0
  703. package/build/dist/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.js.map +1 -1
  704. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +105 -11
  705. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
  706. package/build/dist/Types/Permission.js +174 -0
  707. package/build/dist/Types/Permission.js.map +1 -1
  708. package/build/dist/Types/SerializableObjectDictionary.js +133 -39
  709. package/build/dist/Types/SerializableObjectDictionary.js.map +1 -1
  710. package/build/dist/Types/Telemetry/EntityRelationshipType.js +1 -1
  711. package/build/dist/Types/Telemetry/EntitySource.js +39 -0
  712. package/build/dist/Types/Telemetry/EntitySource.js.map +1 -0
  713. package/build/dist/Types/Telemetry/EntityType.js +28 -1
  714. package/build/dist/Types/Telemetry/EntityType.js.map +1 -1
  715. package/build/dist/Types/Telemetry/EntityTypeGroups.js +57 -0
  716. package/build/dist/Types/Telemetry/EntityTypeGroups.js.map +1 -0
  717. package/build/dist/Types/Workflow/Component.js +21 -0
  718. package/build/dist/Types/Workflow/Component.js.map +1 -1
  719. package/build/dist/Types/Workflow/Components/API.js +35 -0
  720. package/build/dist/Types/Workflow/Components/API.js.map +1 -1
  721. package/build/dist/Types/Workflow/Components/BaseModel.js +68 -26
  722. package/build/dist/Types/Workflow/Components/BaseModel.js.map +1 -1
  723. package/build/dist/Types/Workflow/Components/Discord.js +1 -0
  724. package/build/dist/Types/Workflow/Components/Discord.js.map +1 -1
  725. package/build/dist/Types/Workflow/Components/Email.js +12 -3
  726. package/build/dist/Types/Workflow/Components/Email.js.map +1 -1
  727. package/build/dist/Types/Workflow/Components/JavaScript.js +7 -0
  728. package/build/dist/Types/Workflow/Components/JavaScript.js.map +1 -1
  729. package/build/dist/Types/Workflow/Components/MicrosoftTeams.js +3 -2
  730. package/build/dist/Types/Workflow/Components/MicrosoftTeams.js.map +1 -1
  731. package/build/dist/Types/Workflow/Components/Slack.js +1 -0
  732. package/build/dist/Types/Workflow/Components/Slack.js.map +1 -1
  733. package/build/dist/Types/Workflow/Components/Telegram.js +1 -0
  734. package/build/dist/Types/Workflow/Components/Telegram.js.map +1 -1
  735. package/build/dist/Types/Workflow/StepTrace.js +104 -0
  736. package/build/dist/Types/Workflow/StepTrace.js.map +1 -0
  737. package/build/dist/Types/Workflow/TemplateSyntax.js +338 -0
  738. package/build/dist/Types/Workflow/TemplateSyntax.js.map +1 -0
  739. package/build/dist/Types/Workflow/Templates.js +2111 -0
  740. package/build/dist/Types/Workflow/Templates.js.map +1 -0
  741. package/build/dist/UI/Components/Calendar/Calendar.js +1 -1
  742. package/build/dist/UI/Components/Calendar/Calendar.js.map +1 -1
  743. package/build/dist/UI/Components/Checkbox/Checkbox.js +1 -1
  744. package/build/dist/UI/Components/Checkbox/Checkbox.js.map +1 -1
  745. package/build/dist/UI/Components/Date/CustomTimeRangeModal.js +126 -0
  746. package/build/dist/UI/Components/Date/CustomTimeRangeModal.js.map +1 -0
  747. package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js +123 -0
  748. package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js.map +1 -0
  749. package/build/dist/UI/Components/Dictionary/Dictionary.js +47 -17
  750. package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
  751. package/build/dist/UI/Components/FormModal/BasicFormModal.js.map +1 -1
  752. package/build/dist/UI/Components/Forms/Fields/ColorPicker.js +52 -13
  753. package/build/dist/UI/Components/Forms/Fields/ColorPicker.js.map +1 -1
  754. package/build/dist/UI/Components/Forms/Fields/IconPicker.js +28 -12
  755. package/build/dist/UI/Components/Forms/Fields/IconPicker.js.map +1 -1
  756. package/build/dist/UI/Components/Forms/Validation.js +44 -0
  757. package/build/dist/UI/Components/Forms/Validation.js.map +1 -1
  758. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js +27 -4
  759. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js.map +1 -1
  760. package/build/dist/UI/Components/Input/Input.js +13 -4
  761. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  762. package/build/dist/UI/Components/KeyboardShortcut/KeyboardKey.js +163 -0
  763. package/build/dist/UI/Components/KeyboardShortcut/KeyboardKey.js.map +1 -0
  764. package/build/dist/UI/Components/KeyboardShortcut/KeyboardShortcut.js +47 -0
  765. package/build/dist/UI/Components/KeyboardShortcut/KeyboardShortcut.js.map +1 -0
  766. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +4 -4
  767. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  768. package/build/dist/UI/Components/LogsViewer/components/ActiveFilterChips.js +11 -1
  769. package/build/dist/UI/Components/LogsViewer/components/ActiveFilterChips.js.map +1 -1
  770. package/build/dist/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.js +10 -8
  771. package/build/dist/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.js.map +1 -1
  772. package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js +227 -14
  773. package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js.map +1 -1
  774. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js +5 -108
  775. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js.map +1 -1
  776. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +5 -0
  777. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
  778. package/build/dist/UI/Components/LogsViewer/components/LogsTable.js +69 -5
  779. package/build/dist/UI/Components/LogsViewer/components/LogsTable.js.map +1 -1
  780. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +8 -0
  781. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
  782. package/build/dist/UI/Components/LogsViewer/types.js.map +1 -1
  783. package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js +9 -2
  784. package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js.map +1 -1
  785. package/build/dist/UI/Components/Modal/Modal.js +31 -5
  786. package/build/dist/UI/Components/Modal/Modal.js.map +1 -1
  787. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +8 -21
  788. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
  789. package/build/dist/UI/Components/ProjectInvitations/PendingProjectInvitations.js +251 -0
  790. package/build/dist/UI/Components/ProjectInvitations/PendingProjectInvitations.js.map +1 -0
  791. package/build/dist/UI/Components/SimpleLogViewer/SimpleLogViewer.js +9 -2
  792. package/build/dist/UI/Components/SimpleLogViewer/SimpleLogViewer.js.map +1 -1
  793. package/build/dist/UI/Components/Table/Table.js +27 -15
  794. package/build/dist/UI/Components/Table/Table.js.map +1 -1
  795. package/build/dist/UI/Components/Table/TableBody.js +24 -18
  796. package/build/dist/UI/Components/Table/TableBody.js.map +1 -1
  797. package/build/dist/UI/Components/Table/TableHeader.js +9 -1
  798. package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
  799. package/build/dist/UI/Components/Table/TableRow.js +29 -21
  800. package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
  801. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js +5 -105
  802. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js.map +1 -1
  803. package/build/dist/UI/Components/Workflow/ArgumentsForm.js +261 -14
  804. package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
  805. package/build/dist/UI/Components/Workflow/Component.js +20 -19
  806. package/build/dist/UI/Components/Workflow/Component.js.map +1 -1
  807. package/build/dist/UI/Components/Workflow/ComponentReturnValueViewer.js +10 -1
  808. package/build/dist/UI/Components/Workflow/ComponentReturnValueViewer.js.map +1 -1
  809. package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js +35 -7
  810. package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js.map +1 -1
  811. package/build/dist/UI/Components/Workflow/ComponentValuePickerModal.js +57 -7
  812. package/build/dist/UI/Components/Workflow/ComponentValuePickerModal.js.map +1 -1
  813. package/build/dist/UI/Components/Workflow/ComponentsModal.js +53 -18
  814. package/build/dist/UI/Components/Workflow/ComponentsModal.js.map +1 -1
  815. package/build/dist/UI/Components/Workflow/DocumentationViewer.js +19 -6
  816. package/build/dist/UI/Components/Workflow/DocumentationViewer.js.map +1 -1
  817. package/build/dist/UI/Components/Workflow/GraphLint.js +425 -0
  818. package/build/dist/UI/Components/Workflow/GraphLint.js.map +1 -0
  819. package/build/dist/UI/Components/Workflow/GraphLintSummary.js +231 -0
  820. package/build/dist/UI/Components/Workflow/GraphLintSummary.js.map +1 -0
  821. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js +320 -0
  822. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js.map +1 -0
  823. package/build/dist/UI/Components/Workflow/ModelSchema.js +156 -0
  824. package/build/dist/UI/Components/Workflow/ModelSchema.js.map +1 -0
  825. package/build/dist/UI/Components/Workflow/RunForm.js +22 -6
  826. package/build/dist/UI/Components/Workflow/RunForm.js.map +1 -1
  827. package/build/dist/UI/Components/Workflow/RunStatusWatcher.js +76 -0
  828. package/build/dist/UI/Components/Workflow/RunStatusWatcher.js.map +1 -0
  829. package/build/dist/UI/Components/Workflow/StepTraceViewer.js +76 -0
  830. package/build/dist/UI/Components/Workflow/StepTraceViewer.js.map +1 -0
  831. package/build/dist/UI/Components/Workflow/UseRunWatch.js +123 -0
  832. package/build/dist/UI/Components/Workflow/UseRunWatch.js.map +1 -0
  833. package/build/dist/UI/Components/Workflow/Utils.js +73 -1
  834. package/build/dist/UI/Components/Workflow/Utils.js.map +1 -1
  835. package/build/dist/UI/Components/Workflow/VariableModal.js +3 -2
  836. package/build/dist/UI/Components/Workflow/VariableModal.js.map +1 -1
  837. package/build/dist/UI/Components/Workflow/Workflow.js +92 -7
  838. package/build/dist/UI/Components/Workflow/Workflow.js.map +1 -1
  839. package/build/dist/UI/Components/Workflow/WorkflowIssuesModal.js +99 -0
  840. package/build/dist/UI/Components/Workflow/WorkflowIssuesModal.js.map +1 -0
  841. package/build/dist/UI/Components/Workflow/WorkflowLogModal.js +56 -0
  842. package/build/dist/UI/Components/Workflow/WorkflowLogModal.js.map +1 -0
  843. package/build/dist/UI/Components/Workflow/WorkflowStatusBar.js +92 -0
  844. package/build/dist/UI/Components/Workflow/WorkflowStatusBar.js.map +1 -0
  845. package/build/dist/UI/Types/LayeredDismissal.js +21 -0
  846. package/build/dist/UI/Types/LayeredDismissal.js.map +1 -0
  847. package/build/dist/UI/Types/UseAnchoredFieldPopup.js +74 -1
  848. package/build/dist/UI/Types/UseAnchoredFieldPopup.js.map +1 -1
  849. package/build/dist/UI/Utils/AIChatExport/ConversationMarkdown.js +9 -0
  850. package/build/dist/UI/Utils/AIChatExport/ConversationMarkdown.js.map +1 -1
  851. package/build/dist/UI/Utils/ModelAPI/ModelAPI.js +1 -1
  852. package/build/dist/UI/Utils/ModelAPI/ModelAPI.js.map +1 -1
  853. package/build/dist/UI/Utils/Platform.js +118 -0
  854. package/build/dist/UI/Utils/Platform.js.map +1 -0
  855. package/build/dist/UI/Utils/ProjectInvitationDisplay.js +106 -0
  856. package/build/dist/UI/Utils/ProjectInvitationDisplay.js.map +1 -0
  857. package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js +108 -0
  858. package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js.map +1 -0
  859. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +386 -0
  860. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -0
  861. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +661 -126
  862. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  863. package/build/dist/Utils/Telemetry/CrossSignalScope.js +328 -0
  864. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -0
  865. package/build/dist/Utils/Telemetry/EntityKey.js +57 -5
  866. package/build/dist/Utils/Telemetry/EntityKey.js.map +1 -1
  867. package/build/dist/Utils/Telemetry/EntityRelationship.js +1 -1
  868. package/jest.config.json +1 -0
  869. package/package.json +1 -1
  870. package/build/dist/Models/DatabaseModels/TelemetryEntity.js.map +0 -1
  871. package/build/dist/Models/DatabaseModels/TelemetryEntityRelationship.js.map +0 -1
  872. package/build/dist/Server/Services/TelemetryEntityRelationshipService.js.map +0 -1
  873. 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
+ });