@oneuptime/common 14.0.9 → 14.0.10

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 (383) hide show
  1. package/Models/DatabaseModels/AutoRemediationSuggestion.ts +60 -0
  2. package/Models/DatabaseModels/CephCluster.ts +213 -0
  3. package/Models/DatabaseModels/DatabaseServer.ts +213 -0
  4. package/Models/DatabaseModels/DockerHost.ts +213 -0
  5. package/Models/DatabaseModels/DockerSwarmCluster.ts +213 -0
  6. package/Models/DatabaseModels/GlobalConfig.ts +1 -1
  7. package/Models/DatabaseModels/Host.ts +213 -0
  8. package/Models/DatabaseModels/Index.ts +2 -0
  9. package/Models/DatabaseModels/PodmanHost.ts +213 -0
  10. package/Models/DatabaseModels/ProxmoxCluster.ts +213 -0
  11. package/Models/DatabaseModels/ResourceAiAgent.ts +419 -0
  12. package/Models/DatabaseModels/RunnerJob.ts +144 -0
  13. package/Models/DatabaseModels/VMwareVCenter.ts +213 -0
  14. package/Server/API/AutoRemediationAPI.ts +392 -1
  15. package/Server/API/ResourceAiAccessAPI.ts +1669 -0
  16. package/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.ts +423 -0
  17. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  18. package/Server/Infrastructure/Semaphore.ts +22 -0
  19. package/Server/Middleware/TelemetryIngest.ts +15 -5
  20. package/Server/Services/AnalyticsDatabaseService.ts +45 -1
  21. package/Server/Services/AutoRemediationRuleEngineService.ts +940 -112
  22. package/Server/Services/CephClusterService.ts +109 -1
  23. package/Server/Services/DatabaseServerService.ts +116 -1
  24. package/Server/Services/DockerHostService.ts +109 -1
  25. package/Server/Services/DockerSwarmClusterService.ts +113 -1
  26. package/Server/Services/HostService.ts +126 -4
  27. package/Server/Services/MetricRecordingRuleService.ts +47 -0
  28. package/Server/Services/PodmanHostService.ts +109 -1
  29. package/Server/Services/ProxmoxClusterService.ts +110 -1
  30. package/Server/Services/ResourceAiAccessService.ts +1439 -0
  31. package/Server/Services/ResourceAiAgentJobService.ts +202 -0
  32. package/Server/Services/ResourceAiAgentService.ts +2432 -0
  33. package/Server/Services/RunnerJobService.ts +611 -4
  34. package/Server/Services/TelemetryUsageBillingService.ts +28 -0
  35. package/Server/Services/TraceRecordingRuleService.ts +33 -0
  36. package/Server/Services/VMwareVCenterService.ts +110 -1
  37. package/Server/Utils/AI/Remediation/RemediationCommandTools.ts +1434 -32
  38. package/Server/Utils/AI/Remediation/RemediationExecutionRunner.ts +935 -21
  39. package/Server/Utils/AI/Remediation/RemediationPlanRunner.ts +25 -1
  40. package/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.ts +577 -0
  41. package/Server/Utils/AI/ResourceAccess/ResourceAccessContext.ts +271 -0
  42. package/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.ts +45 -0
  43. package/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.ts +964 -0
  44. package/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.ts +333 -0
  45. package/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.ts +873 -0
  46. package/Server/Utils/AI/SRE/AIInvestigationEngine.ts +72 -2
  47. package/Server/Utils/AI/SRE/AlertInvestigationRunner.ts +72 -5
  48. package/Server/Utils/AI/SRE/IncidentInvestigationRunner.ts +72 -5
  49. package/Server/Utils/AutoRemediation/CommandPlanExecutor.ts +396 -13
  50. package/Server/Utils/AutoRemediation/RemediationVerifier.ts +35 -2
  51. package/Server/Utils/Database/ProjectScopedReferenceValidator.ts +9 -1
  52. package/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.ts +920 -0
  53. package/Server/Utils/SessionReplay/SessionReplayUsage.ts +84 -6
  54. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.ts +35 -15
  55. package/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.ts +33 -15
  56. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.ts +21 -1
  57. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.ts +298 -224
  58. package/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.ts +35 -15
  59. package/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.ts +389 -175
  60. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +366 -143
  61. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.ts +163 -0
  62. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.ts +396 -0
  63. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.ts +418 -0
  64. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.ts +152 -0
  65. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.ts +251 -0
  66. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.ts +214 -0
  67. package/Tests/App/Dashboard/ClusterAccessNotice.test.tsx +93 -0
  68. package/Tests/App/Dashboard/DatabaseDocumentationMarkdown.test.ts +18 -6
  69. package/Tests/App/Dashboard/InvestigationInfrastructureTools.test.tsx +506 -0
  70. package/Tests/App/Dashboard/RemediationSuggestionCardDescription.test.tsx +209 -0
  71. package/Tests/App/Dashboard/RemediationSuggestionCardResource.test.tsx +317 -0
  72. package/Tests/App/Dashboard/ResourceAiAccessSettingsUtil.test.ts +921 -0
  73. package/Tests/App/Dashboard/ResourceAiAgentInstall.test.ts +860 -0
  74. package/Tests/App/Dashboard/ResourceAiAgentPage.test.tsx +1721 -0
  75. package/Tests/App/Dashboard/ResourceAiAgentStatus.test.ts +1002 -0
  76. package/Tests/App/Dashboard/ResourceAiInsightsPage.test.tsx +931 -0
  77. package/Tests/App/Dashboard/ResourceAiNavigation.test.tsx +575 -0
  78. package/Tests/App/Dashboard/RunbookStepTypeMaps.test.ts +38 -7
  79. package/Tests/Models/DatabaseModels/DatabaseServerModels.test.ts +33 -1
  80. package/Tests/Models/DatabaseModels/ResourceAiAccessColumns.test.ts +738 -0
  81. package/Tests/Models/DatabaseModels/ResourceAiAgentModel.test.ts +842 -0
  82. package/Tests/Server/API/AutoRemediationApproveResourceRound.test.ts +835 -0
  83. package/Tests/Server/API/ResourceAiAccessAPI.test.ts +2730 -0
  84. package/Tests/Server/Infrastructure/Postgres/AddDatabaseServerTablesMigration.test.ts +60 -0
  85. package/Tests/Server/Infrastructure/Postgres/AddResourceAiAgentsMigration.test.ts +659 -0
  86. package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +31 -0
  87. package/Tests/Server/Middleware/TelemetryIngestBrowserKey.test.ts +63 -5
  88. package/Tests/Server/Middleware/TelemetryIngestKubernetesAgentRunnerPinnedKey.test.ts +103 -5
  89. package/Tests/Server/Middleware/TelemetryIngestKubernetesAgentRunnerRateLimit.test.ts +19 -1
  90. package/Tests/Server/Services/AutoRemediationResourceRuleEngine.test.ts +1279 -0
  91. package/Tests/Server/Services/DatabaseServerService.test.ts +55 -0
  92. package/Tests/Server/Services/GroupTelemetryUsageExcludeNames.test.ts +299 -0
  93. package/Tests/Server/Services/HostServiceFindOrCreateMemo.test.ts +44 -0
  94. package/Tests/Server/Services/MonitorProbeServiceIntervalScheduling.test.ts +45 -6
  95. package/Tests/Server/Services/RecordingRuleReservedMetricName.test.ts +171 -0
  96. package/Tests/Server/Services/ResourceAiAccessChangeAuthorization.test.ts +256 -0
  97. package/Tests/Server/Services/ResourceAiAccessService.test.ts +1373 -0
  98. package/Tests/Server/Services/ResourceAiAgentJobService.test.ts +375 -0
  99. package/Tests/Server/Services/ResourceAiAgentServiceHelpers.test.ts +1114 -0
  100. package/Tests/Server/Services/ResourceAiAgentServiceLifecycle.test.ts +1050 -0
  101. package/Tests/Server/Services/ResourceAiAgentServiceRegister.test.ts +2251 -0
  102. package/Tests/Server/Services/ResourceAiSettingsCreate.test.ts +373 -0
  103. package/Tests/Server/Services/ResourceAiSettingsPermission.test.ts +1042 -0
  104. package/Tests/Server/Services/ResourceServiceDeleteCleansUpAi.test.ts +319 -0
  105. package/Tests/Server/Services/RunnerJobEnqueueKubectl.test.ts +105 -0
  106. package/Tests/Server/Services/RunnerJobEnqueueResourceCommand.test.ts +986 -0
  107. package/Tests/Server/Services/RunnerJobResourceCommandLane.test.ts +211 -0
  108. package/Tests/Server/Services/RunnerJobResourceCommandTimeoutAndRedaction.test.ts +352 -0
  109. package/Tests/Server/Services/TelemetryUsageBillingSloExclusion.test.ts +218 -0
  110. package/Tests/Server/TestingUtils/Services/FakeRunnerJobCount.ts +145 -0
  111. package/Tests/Server/Utils/AI/InvestigationInfrastructureAccessWiring.test.ts +453 -0
  112. package/Tests/Server/Utils/AI/InvestigationInfrastructureReport.test.ts +721 -0
  113. package/Tests/Server/Utils/AI/RemediationCommandTools.test.ts +94 -1
  114. package/Tests/Server/Utils/AI/RemediationCommandToolsResource.test.ts +1530 -0
  115. package/Tests/Server/Utils/AI/RemediationExecutionRunnerResourceMode.test.ts +1249 -0
  116. package/Tests/Server/Utils/AI/RemediationPlanRunner.test.ts +117 -0
  117. package/Tests/Server/Utils/AI/RemediationResourceCopyParity.test.ts +463 -0
  118. package/Tests/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.test.ts +722 -0
  119. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAccessContext.test.ts +266 -0
  120. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.test.ts +1220 -0
  121. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.test.ts +569 -0
  122. package/Tests/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.test.ts +830 -0
  123. package/Tests/Server/Utils/AutoRemediation/CommandPlanExecutorResource.test.ts +717 -0
  124. package/Tests/Server/Utils/AutoRemediation/RemediationVerifierResourceFollowUp.test.ts +338 -0
  125. package/Tests/Server/Utils/Monitor/Criteria/SessionReplayBudgetTemplateCriteria.test.ts +422 -0
  126. package/Tests/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.test.ts +1647 -0
  127. package/Tests/Server/Utils/SessionReplay/SessionReplayUsage.test.ts +143 -1
  128. package/Tests/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.test.ts +33 -3
  129. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsAccountNotLinked.test.ts +1093 -0
  130. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.test.ts +1049 -0
  131. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.test.ts +1676 -0
  132. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCards.test.ts +2884 -0
  133. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.test.ts +2490 -0
  134. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateSubmit.test.ts +2293 -0
  135. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateSubmitServerTimezone.test.ts +386 -0
  136. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.test.ts +1477 -0
  137. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.test.ts +1967 -0
  138. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsStripHtmlTags.test.ts +92 -0
  139. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsSubmittedFormRemoval.test.ts +1819 -0
  140. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.test.ts +1033 -0
  141. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezoneServerZone.test.ts +610 -0
  142. package/Tests/Server/Utils/Workspace/MicrosoftTeamsBotMessageHandling.test.ts +2610 -0
  143. package/Tests/Server/Utils/Workspace/MicrosoftTeamsCreateCommandsEndToEnd.test.ts +4672 -0
  144. package/Tests/Server/Utils/Workspace/WorkspaceCreateProjectReferences.test.ts +190 -65
  145. package/Tests/Types/AutoRemediation/AiRemediationCommandPlan.test.ts +10 -4
  146. package/Tests/Types/AutoRemediation/AiRemediationCommandPlanResourceCommand.test.ts +305 -0
  147. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCatalog.test.ts +277 -13
  148. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationUtil.test.ts +113 -0
  149. package/Tests/Types/Monitor/RumAlertTemplates.test.ts +773 -2
  150. package/Tests/Types/ResourceAiAgent/AiResourceType.test.ts +419 -0
  151. package/Tests/Types/ResourceAiAgent/ResourceAiAccess.test.ts +517 -0
  152. package/Tests/Types/Runbook/RunbookStepType.test.ts +147 -8
  153. package/Tests/UI/Rum/RecordingHealthDashboard.test.tsx +769 -1
  154. package/Tests/Utils/AiRemediation/Resource/CephCommandPolicy.test.ts +1653 -0
  155. package/Tests/Utils/AiRemediation/Resource/CephOutputRedaction.test.ts +204 -0
  156. package/Tests/Utils/AiRemediation/Resource/DatabaseCommandPolicy.test.ts +1077 -0
  157. package/Tests/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.test.ts +558 -0
  158. package/Tests/Utils/AiRemediation/Resource/DatabaseQueryRedactor.test.ts +834 -0
  159. package/Tests/Utils/AiRemediation/Resource/DockerCliGrammar.test.ts +747 -0
  160. package/Tests/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.test.ts +1259 -0
  161. package/Tests/Utils/AiRemediation/Resource/DockerOutputRedaction.test.ts +386 -0
  162. package/Tests/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.test.ts +782 -0
  163. package/Tests/Utils/AiRemediation/Resource/GovcCommandPolicy.test.ts +2259 -0
  164. package/Tests/Utils/AiRemediation/Resource/HostCommandPolicy.test.ts +2019 -0
  165. package/Tests/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.test.ts +2497 -0
  166. package/Tests/Utils/AiRemediation/Resource/ResourceCommandPolicy.test.ts +1305 -0
  167. package/Tests/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.test.ts +585 -0
  168. package/Tests/Utils/AiRemediation/Resource/ResourceOutputRedactor.test.ts +334 -0
  169. package/Tests/Utils/AiRemediation/Resource/ResourceOutputRedactorCopyParity.test.ts +98 -0
  170. package/Tests/Utils/AiRemediation/Resource/ResourcePolicyImportClosure.test.ts +391 -0
  171. package/Tests/Utils/AiRemediation/ResourceAiAgentPolicyCopyParity.test.ts +497 -0
  172. package/Tests/Utils/SessionReplay/SessionReplayBudgetMetricType.test.ts +383 -0
  173. package/Types/AI/ResourceAiAccessApi.ts +140 -0
  174. package/Types/AI/ResourceAiAccessPermissions.ts +126 -0
  175. package/Types/AutoRemediation/AiRemediationCommandPlan.ts +183 -2
  176. package/Types/Monitor/Recommendation/MonitorRecommendationCatalog.ts +55 -22
  177. package/Types/Monitor/Recommendation/MonitorRecommendationTypes.ts +35 -3
  178. package/Types/Monitor/RumAlertTemplates.ts +351 -4
  179. package/Types/ResourceAiAgent/AiResourceType.ts +310 -0
  180. package/Types/ResourceAiAgent/ResourceAiAccess.ts +574 -0
  181. package/Types/Rum/SessionReplayBudgetMetricType.ts +47 -0
  182. package/Types/Runbook/RunbookStepType.ts +29 -0
  183. package/Types/Telemetry/TelemetryIngestSurface.ts +14 -2
  184. package/Utils/AI/InvestigationReport.ts +121 -15
  185. package/Utils/AiRemediation/Resource/CephCommandPolicy.ts +1936 -0
  186. package/Utils/AiRemediation/Resource/DatabaseCommandPolicy.ts +166 -0
  187. package/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.ts +1532 -0
  188. package/Utils/AiRemediation/Resource/DatabaseQueryRedactor.ts +1394 -0
  189. package/Utils/AiRemediation/Resource/DockerCliGrammar.ts +1839 -0
  190. package/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.ts +490 -0
  191. package/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.ts +687 -0
  192. package/Utils/AiRemediation/Resource/GovcCommandPolicy.ts +2096 -0
  193. package/Utils/AiRemediation/Resource/HostCommandPolicy.ts +2982 -0
  194. package/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.ts +1679 -0
  195. package/Utils/AiRemediation/Resource/ResourceCommandPolicy.ts +851 -0
  196. package/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.ts +593 -0
  197. package/Utils/AiRemediation/Resource/ResourceOutputRedactor.ts +2812 -0
  198. package/Utils/SessionReplay/SessionReplayBudgetMetricType.ts +184 -0
  199. package/build/dist/Models/DatabaseModels/AutoRemediationSuggestion.js +62 -0
  200. package/build/dist/Models/DatabaseModels/AutoRemediationSuggestion.js.map +1 -1
  201. package/build/dist/Models/DatabaseModels/CephCluster.js +219 -0
  202. package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
  203. package/build/dist/Models/DatabaseModels/DatabaseServer.js +219 -0
  204. package/build/dist/Models/DatabaseModels/DatabaseServer.js.map +1 -1
  205. package/build/dist/Models/DatabaseModels/DockerHost.js +219 -0
  206. package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
  207. package/build/dist/Models/DatabaseModels/DockerSwarmCluster.js +219 -0
  208. package/build/dist/Models/DatabaseModels/DockerSwarmCluster.js.map +1 -1
  209. package/build/dist/Models/DatabaseModels/GlobalConfig.js +1 -1
  210. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  211. package/build/dist/Models/DatabaseModels/Host.js +219 -0
  212. package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
  213. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  214. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  215. package/build/dist/Models/DatabaseModels/PodmanHost.js +219 -0
  216. package/build/dist/Models/DatabaseModels/PodmanHost.js.map +1 -1
  217. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +219 -0
  218. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
  219. package/build/dist/Models/DatabaseModels/ResourceAiAgent.js +439 -0
  220. package/build/dist/Models/DatabaseModels/ResourceAiAgent.js.map +1 -0
  221. package/build/dist/Models/DatabaseModels/RunnerJob.js +145 -0
  222. package/build/dist/Models/DatabaseModels/RunnerJob.js.map +1 -1
  223. package/build/dist/Models/DatabaseModels/VMwareVCenter.js +219 -0
  224. package/build/dist/Models/DatabaseModels/VMwareVCenter.js.map +1 -1
  225. package/build/dist/Server/API/AutoRemediationAPI.js +231 -1
  226. package/build/dist/Server/API/AutoRemediationAPI.js.map +1 -1
  227. package/build/dist/Server/API/ResourceAiAccessAPI.js +1119 -0
  228. package/build/dist/Server/API/ResourceAiAccessAPI.js.map +1 -0
  229. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.js +168 -0
  230. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.js.map +1 -0
  231. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  232. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  233. package/build/dist/Server/Infrastructure/Semaphore.js +6 -0
  234. package/build/dist/Server/Infrastructure/Semaphore.js.map +1 -1
  235. package/build/dist/Server/Middleware/TelemetryIngest.js +12 -5
  236. package/build/dist/Server/Middleware/TelemetryIngest.js.map +1 -1
  237. package/build/dist/Server/Services/AnalyticsDatabaseService.js +26 -1
  238. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  239. package/build/dist/Server/Services/AutoRemediationRuleEngineService.js +601 -3
  240. package/build/dist/Server/Services/AutoRemediationRuleEngineService.js.map +1 -1
  241. package/build/dist/Server/Services/CephClusterService.js +104 -0
  242. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  243. package/build/dist/Server/Services/DatabaseServerService.js +99 -0
  244. package/build/dist/Server/Services/DatabaseServerService.js.map +1 -1
  245. package/build/dist/Server/Services/DockerHostService.js +104 -0
  246. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  247. package/build/dist/Server/Services/DockerSwarmClusterService.js +104 -0
  248. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  249. package/build/dist/Server/Services/HostService.js +114 -2
  250. package/build/dist/Server/Services/HostService.js.map +1 -1
  251. package/build/dist/Server/Services/MetricRecordingRuleService.js +46 -0
  252. package/build/dist/Server/Services/MetricRecordingRuleService.js.map +1 -1
  253. package/build/dist/Server/Services/PodmanHostService.js +104 -0
  254. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  255. package/build/dist/Server/Services/ProxmoxClusterService.js +104 -0
  256. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  257. package/build/dist/Server/Services/ResourceAiAccessService.js +1028 -0
  258. package/build/dist/Server/Services/ResourceAiAccessService.js.map +1 -0
  259. package/build/dist/Server/Services/ResourceAiAgentJobService.js +173 -0
  260. package/build/dist/Server/Services/ResourceAiAgentJobService.js.map +1 -0
  261. package/build/dist/Server/Services/ResourceAiAgentService.js +1687 -0
  262. package/build/dist/Server/Services/ResourceAiAgentService.js.map +1 -0
  263. package/build/dist/Server/Services/RunnerJobService.js +344 -5
  264. package/build/dist/Server/Services/RunnerJobService.js.map +1 -1
  265. package/build/dist/Server/Services/TelemetryUsageBillingService.js +26 -0
  266. package/build/dist/Server/Services/TelemetryUsageBillingService.js.map +1 -1
  267. package/build/dist/Server/Services/TraceRecordingRuleService.js +37 -0
  268. package/build/dist/Server/Services/TraceRecordingRuleService.js.map +1 -1
  269. package/build/dist/Server/Services/VMwareVCenterService.js +104 -0
  270. package/build/dist/Server/Services/VMwareVCenterService.js.map +1 -1
  271. package/build/dist/Server/Utils/AI/Remediation/RemediationCommandTools.js +1029 -30
  272. package/build/dist/Server/Utils/AI/Remediation/RemediationCommandTools.js.map +1 -1
  273. package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js +633 -28
  274. package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js.map +1 -1
  275. package/build/dist/Server/Utils/AI/Remediation/RemediationPlanRunner.js +21 -1
  276. package/build/dist/Server/Utils/AI/Remediation/RemediationPlanRunner.js.map +1 -1
  277. package/build/dist/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.js +330 -0
  278. package/build/dist/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.js.map +1 -0
  279. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessContext.js +160 -0
  280. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessContext.js.map +1 -0
  281. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.js +33 -0
  282. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.js.map +1 -0
  283. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.js +640 -0
  284. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.js.map +1 -0
  285. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.js +226 -0
  286. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.js.map +1 -0
  287. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.js +600 -0
  288. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.js.map +1 -0
  289. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js +50 -4
  290. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js.map +1 -1
  291. package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js +55 -4
  292. package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js.map +1 -1
  293. package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js +55 -4
  294. package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js.map +1 -1
  295. package/build/dist/Server/Utils/AutoRemediation/CommandPlanExecutor.js +290 -13
  296. package/build/dist/Server/Utils/AutoRemediation/CommandPlanExecutor.js.map +1 -1
  297. package/build/dist/Server/Utils/AutoRemediation/RemediationVerifier.js +29 -1
  298. package/build/dist/Server/Utils/AutoRemediation/RemediationVerifier.js.map +1 -1
  299. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js +9 -1
  300. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js.map +1 -1
  301. package/build/dist/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.js +627 -0
  302. package/build/dist/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.js.map +1 -0
  303. package/build/dist/Server/Utils/SessionReplay/SessionReplayUsage.js +60 -5
  304. package/build/dist/Server/Utils/SessionReplay/SessionReplayUsage.js.map +1 -1
  305. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js +19 -15
  306. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js.map +1 -1
  307. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.js +19 -15
  308. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.js.map +1 -1
  309. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js +17 -1
  310. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js.map +1 -1
  311. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js +183 -209
  312. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js.map +1 -1
  313. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.js +19 -15
  314. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.js.map +1 -1
  315. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.js +233 -167
  316. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.js.map +1 -1
  317. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +263 -110
  318. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  319. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.js +129 -0
  320. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.js.map +1 -0
  321. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.js +266 -0
  322. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.js.map +1 -0
  323. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.js +258 -0
  324. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.js.map +1 -0
  325. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.js +104 -0
  326. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.js.map +1 -0
  327. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.js +183 -0
  328. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.js.map +1 -0
  329. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.js +123 -0
  330. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.js.map +1 -0
  331. package/build/dist/Types/AI/ResourceAiAccessApi.js +30 -0
  332. package/build/dist/Types/AI/ResourceAiAccessApi.js.map +1 -0
  333. package/build/dist/Types/AI/ResourceAiAccessPermissions.js +98 -0
  334. package/build/dist/Types/AI/ResourceAiAccessPermissions.js.map +1 -0
  335. package/build/dist/Types/AutoRemediation/AiRemediationCommandPlan.js +116 -29
  336. package/build/dist/Types/AutoRemediation/AiRemediationCommandPlan.js.map +1 -1
  337. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js +47 -21
  338. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js.map +1 -1
  339. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js +5 -0
  340. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js.map +1 -1
  341. package/build/dist/Types/Monitor/RumAlertTemplates.js +199 -3
  342. package/build/dist/Types/Monitor/RumAlertTemplates.js.map +1 -1
  343. package/build/dist/Types/ResourceAiAgent/AiResourceType.js +242 -0
  344. package/build/dist/Types/ResourceAiAgent/AiResourceType.js.map +1 -0
  345. package/build/dist/Types/ResourceAiAgent/ResourceAiAccess.js +275 -0
  346. package/build/dist/Types/ResourceAiAgent/ResourceAiAccess.js.map +1 -0
  347. package/build/dist/Types/Rum/SessionReplayBudgetMetricType.js +45 -0
  348. package/build/dist/Types/Rum/SessionReplayBudgetMetricType.js.map +1 -0
  349. package/build/dist/Types/Runbook/RunbookStepType.js +27 -0
  350. package/build/dist/Types/Runbook/RunbookStepType.js.map +1 -1
  351. package/build/dist/Types/Telemetry/TelemetryIngestSurface.js +13 -2
  352. package/build/dist/Types/Telemetry/TelemetryIngestSurface.js.map +1 -1
  353. package/build/dist/Utils/AI/InvestigationReport.js +71 -11
  354. package/build/dist/Utils/AI/InvestigationReport.js.map +1 -1
  355. package/build/dist/Utils/AiRemediation/Resource/CephCommandPolicy.js +1462 -0
  356. package/build/dist/Utils/AiRemediation/Resource/CephCommandPolicy.js.map +1 -0
  357. package/build/dist/Utils/AiRemediation/Resource/DatabaseCommandPolicy.js +122 -0
  358. package/build/dist/Utils/AiRemediation/Resource/DatabaseCommandPolicy.js.map +1 -0
  359. package/build/dist/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.js +1139 -0
  360. package/build/dist/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.js.map +1 -0
  361. package/build/dist/Utils/AiRemediation/Resource/DatabaseQueryRedactor.js +1060 -0
  362. package/build/dist/Utils/AiRemediation/Resource/DatabaseQueryRedactor.js.map +1 -0
  363. package/build/dist/Utils/AiRemediation/Resource/DockerCliGrammar.js +1301 -0
  364. package/build/dist/Utils/AiRemediation/Resource/DockerCliGrammar.js.map +1 -0
  365. package/build/dist/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.js +370 -0
  366. package/build/dist/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.js.map +1 -0
  367. package/build/dist/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.js +498 -0
  368. package/build/dist/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.js.map +1 -0
  369. package/build/dist/Utils/AiRemediation/Resource/GovcCommandPolicy.js +1565 -0
  370. package/build/dist/Utils/AiRemediation/Resource/GovcCommandPolicy.js.map +1 -0
  371. package/build/dist/Utils/AiRemediation/Resource/HostCommandPolicy.js +2206 -0
  372. package/build/dist/Utils/AiRemediation/Resource/HostCommandPolicy.js.map +1 -0
  373. package/build/dist/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.js +1129 -0
  374. package/build/dist/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.js.map +1 -0
  375. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicy.js +565 -0
  376. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicy.js.map +1 -0
  377. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.js +408 -0
  378. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.js.map +1 -0
  379. package/build/dist/Utils/AiRemediation/Resource/ResourceOutputRedactor.js +1910 -0
  380. package/build/dist/Utils/AiRemediation/Resource/ResourceOutputRedactor.js.map +1 -0
  381. package/build/dist/Utils/SessionReplay/SessionReplayBudgetMetricType.js +160 -0
  382. package/build/dist/Utils/SessionReplay/SessionReplayBudgetMetricType.js.map +1 -0
  383. package/package.json +1 -1
@@ -0,0 +1,1477 @@
1
+ /*
2
+ * Pins MicrosoftTeamsMessageSize: how the Microsoft Teams bot measures a
3
+ * message, how it recognises Teams refusing one as too large, and how it cuts
4
+ * a text reply down to a budget.
5
+ *
6
+ * Issue #4111: in Teams, "create incident" and "create maintenance" answered
7
+ * "Sorry, I encountered an error processing your request. Please try again
8
+ * later." (twice), while "show scheduled maintenance" worked. The two forms
9
+ * listed every monitor, label and on-call policy of the project, and Teams
10
+ * refused the card with HTTP 413 MessageSizeTooBig ("Message size too
11
+ * large."). Teams measures a bot message as UTF-16, two bytes a code unit, so
12
+ * an emoji (a surrogate pair) costs four. The Bot Framework surfaces the
13
+ * refusal as a RestError carrying statusCode 413 and code "MessageSizeTooBig",
14
+ * and that error can come from another JavaScript realm, where instanceof does
15
+ * not work - so it has to be recognised by its fields.
16
+ *
17
+ * Covered here: getSizeInBytes (a string as UTF-16, an object as the JSON that
18
+ * is sent), isMessageTooLargeError / getErrorStatusCode / getErrorCode (every
19
+ * field a refusal can arrive in, and everything that is not a refusal), the
20
+ * budgets themselves, and fitTextToBudget: untouched within the budget; over
21
+ * it, never past the budget, cut at the last line break that fits (a line
22
+ * that ends exactly where the budget ends included) or else between whole
23
+ * characters, and followed by the note when the note fits as well - a note
24
+ * that does not fit is left off, and the whole budget goes to the text. Two
25
+ * sweeps check those promises: a seeded one over hundreds of mixed texts at
26
+ * budgets the note fits in, and one over every budget from nothing upwards.
27
+ *
28
+ * The review of #4111 fixed a bug these tests reported: a whole line that
29
+ * ended exactly where the budget ended was dropped (see "keeps a whole line
30
+ * that ends exactly where the budget ends", which checks the reported
31
+ * example, the same with Windows line endings, and every budget over a reply
32
+ * of lines of different lengths). It also changed what a budget smaller than
33
+ * the note gets: the note used to be returned whole, over the budget; now the
34
+ * text gets the budget (see "gives the whole budget to the text when the note
35
+ * does not fit").
36
+ */
37
+
38
+ import { describe, expect, test } from "@jest/globals";
39
+ import vm from "vm";
40
+ import MicrosoftTeamsMessageSize, {
41
+ MICROSOFT_TEAMS_CARD_SIZE_BUDGETS_IN_BYTES,
42
+ MICROSOFT_TEAMS_TEXT_MESSAGE_BUDGET_IN_BYTES,
43
+ } from "../../../../../Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize";
44
+ import BadDataException from "../../../../../Types/Exception/BadDataException";
45
+ import { JSONObject } from "../../../../../Types/JSON";
46
+
47
+ // The note fitTextToBudget appends when the caller does not bring its own.
48
+ const DEFAULT_TRUNCATION_NOTE: string =
49
+ "…\n\n_This reply was shortened to fit in Microsoft Teams. Open OneUptime to see everything._";
50
+
51
+ // Microsoft: "keep a bot message within 80 KB" (the hard limit is ~100 KB).
52
+ const TEAMS_RECOMMENDED_MAX_MESSAGE_BYTES: number = 80 * 1024;
53
+
54
+ // U+1F600, one character but two UTF-16 code units (a surrogate pair).
55
+ const GRINNING_FACE: string = "😀";
56
+ const GRINNING_FACE_HIGH_SURROGATE: string = "\uD83D";
57
+
58
+ /*
59
+ * A string made only of whole characters: no high surrogate without its low
60
+ * half after it, and no low surrogate on its own.
61
+ */
62
+ const WELL_FORMED_UTF16: RegExp =
63
+ /^(?:[^\uD800-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF])*$/;
64
+
65
+ const STARTS_WITH_WHITESPACE: RegExp = /^\s/;
66
+
67
+ const ENDS_WITH_HIGH_SURROGATE: RegExp = /[\uD800-\uDBFF]$/;
68
+
69
+ /*
70
+ * What a cut at a line break may drop of the part that fits: blanks, the line
71
+ * break it cut at, and the start of a line that did not fit.
72
+ */
73
+ const DROPPED_AT_A_LINE_BREAK: RegExp = /^\s*\n[^\n]*$/;
74
+
75
+ /*
76
+ * What a cut inside a line may drop of the part that fits: blanks, and the
77
+ * first half of an emoji the budget ends inside.
78
+ */
79
+ const DROPPED_MID_LINE: RegExp = /^\s*[\uD800-\uDBFF]?$/;
80
+
81
+ // mulberry32: a small seeded generator, so a sweep is the same on every run.
82
+ function createSeededRandom(seed: number): () => number {
83
+ let state: number = seed;
84
+
85
+ return (): number => {
86
+ state = (state + 0x6d2b79f5) | 0;
87
+ let mixed: number = Math.imul(state ^ (state >>> 15), 1 | state);
88
+ mixed = (mixed + Math.imul(mixed ^ (mixed >>> 7), 61 | mixed)) ^ mixed;
89
+ return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296;
90
+ };
91
+ }
92
+
93
+ const TEAMS_MESSAGE_SIZE_TOO_BIG_BODY: JSONObject = {
94
+ error: { code: "MessageSizeTooBig", message: "Message size too large." },
95
+ };
96
+
97
+ interface RestErrorOptions {
98
+ message: string;
99
+ statusCode?: number | undefined;
100
+ code?: string | undefined;
101
+ details?: JSONObject | undefined;
102
+ responseStatus?: number | undefined;
103
+ }
104
+
105
+ /*
106
+ * What the Bot Framework connector throws (@azure/core-rest-pipeline's
107
+ * RestError): an Error named "RestError" with code and statusCode of its own,
108
+ * the parsed error body as details, and the response as a NON-enumerable
109
+ * property, so reading it must not depend on enumeration.
110
+ */
111
+ function buildRestError(options: RestErrorOptions): Error {
112
+ const error: Error = new Error(options.message);
113
+ error.name = "RestError";
114
+
115
+ Object.assign(error, {
116
+ code: options.code,
117
+ statusCode: options.statusCode,
118
+ });
119
+
120
+ if (options.details) {
121
+ Object.assign(error, { details: options.details });
122
+ }
123
+
124
+ Object.defineProperty(error, "response", {
125
+ value:
126
+ options.responseStatus === undefined
127
+ ? undefined
128
+ : { status: options.responseStatus },
129
+ enumerable: false,
130
+ });
131
+
132
+ return error;
133
+ }
134
+
135
+ // Teams refusing a bot message as too large, as the connector reports it.
136
+ function buildTeamsMessageTooLargeError(): Error {
137
+ return buildRestError({
138
+ message: "Message size too large.",
139
+ statusCode: 413,
140
+ code: "MessageSizeTooBig",
141
+ details: TEAMS_MESSAGE_SIZE_TOO_BIG_BODY,
142
+ responseStatus: 413,
143
+ });
144
+ }
145
+
146
+ /*
147
+ * The same refusal created in another JavaScript realm (a vm context), where
148
+ * `error instanceof Error` is false in this realm.
149
+ */
150
+ function buildTeamsMessageTooLargeErrorFromAnotherRealm(): unknown {
151
+ return vm.runInNewContext(`
152
+ const error = new Error("Message size too large.");
153
+ error.name = "RestError";
154
+ error.code = "MessageSizeTooBig";
155
+ error.statusCode = 413;
156
+ error.details = {
157
+ error: { code: "MessageSizeTooBig", message: "Message size too large." },
158
+ };
159
+ Object.defineProperty(error, "response", {
160
+ value: { status: 413 },
161
+ enumerable: false,
162
+ });
163
+ error;
164
+ `);
165
+ }
166
+
167
+ interface ErrorCase {
168
+ description: string;
169
+ error: unknown;
170
+ }
171
+
172
+ const TOO_LARGE_ERRORS: Array<ErrorCase> = [
173
+ {
174
+ description:
175
+ "the connector's RestError (413, MessageSizeTooBig, parsed body, response)",
176
+ error: buildTeamsMessageTooLargeError(),
177
+ },
178
+ {
179
+ description: "the same RestError as a plain object",
180
+ error: {
181
+ name: "RestError",
182
+ message: "Message size too large.",
183
+ statusCode: 413,
184
+ code: "MessageSizeTooBig",
185
+ details: TEAMS_MESSAGE_SIZE_TOO_BIG_BODY,
186
+ },
187
+ },
188
+ {
189
+ description: "the RestError created in another JavaScript realm",
190
+ error: buildTeamsMessageTooLargeErrorFromAnotherRealm(),
191
+ },
192
+ {
193
+ description: "an error with only statusCode 413",
194
+ error: { statusCode: 413 },
195
+ },
196
+ {
197
+ description:
198
+ "an error with only response.status 413 (a proxy answered 413 with a body that is not JSON)",
199
+ error: { response: { status: 413 } },
200
+ },
201
+ {
202
+ description:
203
+ "a RestError whose only status is its non-enumerable response.status 413",
204
+ error: buildRestError({
205
+ message: "<html><body>413 Request Entity Too Large</body></html>",
206
+ responseStatus: 413,
207
+ }),
208
+ },
209
+ {
210
+ description: "an error with only details.error.code MessageSizeTooBig",
211
+ error: { details: { error: { code: "MessageSizeTooBig" } } },
212
+ },
213
+ {
214
+ description: "an error with only code MessageSizeTooBig",
215
+ error: { code: "MessageSizeTooBig" },
216
+ },
217
+ {
218
+ description: "status 413 with a code other than MessageSizeTooBig",
219
+ error: { statusCode: 413, code: "RequestEntityTooLarge" },
220
+ },
221
+ {
222
+ description: "code MessageSizeTooBig with a status other than 413",
223
+ error: { statusCode: 400, code: "MessageSizeTooBig" },
224
+ },
225
+ ];
226
+
227
+ const OTHER_ERRORS: Array<ErrorCase> = [
228
+ {
229
+ description: "a 400 RestError",
230
+ error: buildRestError({
231
+ message: "The activity is missing a required field.",
232
+ statusCode: 400,
233
+ code: "BadSyntax",
234
+ responseStatus: 400,
235
+ }),
236
+ },
237
+ {
238
+ description:
239
+ "a 403 RestError (the bot was removed from the conversation roster)",
240
+ error: buildRestError({
241
+ message: "The bot is not part of the conversation roster.",
242
+ statusCode: 403,
243
+ code: "BotNotInConversationRoster",
244
+ responseStatus: 403,
245
+ }),
246
+ },
247
+ {
248
+ description: "a 404 RestError",
249
+ error: buildRestError({
250
+ message: "Conversation not found.",
251
+ statusCode: 404,
252
+ code: "ConversationNotFound",
253
+ responseStatus: 404,
254
+ }),
255
+ },
256
+ {
257
+ description: "a 500 RestError",
258
+ error: buildRestError({
259
+ message: "Internal server error.",
260
+ statusCode: 500,
261
+ code: "InternalServerError",
262
+ responseStatus: 500,
263
+ }),
264
+ },
265
+ {
266
+ description: "a RestError for a request that never reached Teams",
267
+ error: buildRestError({
268
+ message: "getaddrinfo ENOTFOUND smba.trafficmanager.net",
269
+ code: "REQUEST_SEND_ERROR",
270
+ }),
271
+ },
272
+ {
273
+ description: "a plain Error",
274
+ error: new Error("Something went wrong."),
275
+ },
276
+ {
277
+ description:
278
+ "an error that says it is too large only in its message (the text is not read)",
279
+ error: new Error("Message size too large."),
280
+ },
281
+ {
282
+ description: "a Node system error (string code, no status)",
283
+ error: Object.assign(new Error("socket hang up"), {
284
+ code: "ECONNRESET",
285
+ }),
286
+ },
287
+ {
288
+ description:
289
+ "a OneUptime BadDataException (its numeric code is not an HTTP status)",
290
+ error: new BadDataException("Title is required."),
291
+ },
292
+ {
293
+ description: "a status of 413 given as a string",
294
+ error: { statusCode: "413", response: { status: "413" } },
295
+ },
296
+ {
297
+ description: "an empty object",
298
+ error: {},
299
+ },
300
+ { description: "null", error: null },
301
+ { description: "undefined", error: undefined },
302
+ { description: "the string MessageSizeTooBig", error: "MessageSizeTooBig" },
303
+ { description: "the number 413", error: 413 },
304
+ { description: "true", error: true },
305
+ ];
306
+
307
+ describe("MicrosoftTeamsMessageSize.getSizeInBytes", () => {
308
+ test("counts two bytes for every UTF-16 code unit of a string", () => {
309
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes("")).toBe(0);
310
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes("hello")).toBe(10);
311
+ });
312
+
313
+ test("counts an accented Latin letter as one code unit", () => {
314
+ // "é" is U+00E9: one UTF-16 unit, two UTF-8 bytes.
315
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes("héllo")).toBe(10);
316
+ });
317
+
318
+ test("counts CJK characters as UTF-16, not UTF-8", () => {
319
+ const text: string = "漢字テスト";
320
+
321
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes(text)).toBe(10);
322
+ // The same text is 15 bytes as UTF-8; Teams counts UTF-16.
323
+ expect(Buffer.byteLength(text, "utf8")).toBe(15);
324
+ });
325
+
326
+ test("counts an emoji (a surrogate pair) as two code units, four bytes", () => {
327
+ expect(GRINNING_FACE.length).toBe(2);
328
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes(GRINNING_FACE)).toBe(4);
329
+ });
330
+
331
+ test("counts every code unit of a composed emoji", () => {
332
+ // Thumbs up + medium skin tone: two surrogate pairs.
333
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes("👍🏽")).toBe(8);
334
+
335
+ // Man, ZWJ, woman, ZWJ, girl: three surrogate pairs and two joiners.
336
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes("👨‍👩‍👧")).toBe(16);
337
+ });
338
+
339
+ test("measures a string as it is, without JSON-encoding it", () => {
340
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes('say "hi"')).toBe(16);
341
+ });
342
+
343
+ test("measures an object as its JSON", () => {
344
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes({})).toBe(4);
345
+ // '{"text":"hello"}' is 16 code units.
346
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes({ text: "hello" })).toBe(
347
+ 32,
348
+ );
349
+ });
350
+
351
+ test("measures non-ASCII text in an object as UTF-16 code units", () => {
352
+ // '{"title":"漢字"}' is 14 code units.
353
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes({ title: "漢字" })).toBe(
354
+ 28,
355
+ );
356
+ // '{"t":"<emoji>"}' is 10 code units: the emoji counts twice.
357
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes({ t: GRINNING_FACE })).toBe(
358
+ 20,
359
+ );
360
+ });
361
+
362
+ test("counts the escapes JSON adds, as they are sent", () => {
363
+ const card: JSONObject = { text: 'say "hi"\n' };
364
+
365
+ // '{"text":"say \"hi\"\n"}' is 23 code units: each escape adds one.
366
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes(card)).toBe(46);
367
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes(card)).toBe(
368
+ JSON.stringify(card).length * 2,
369
+ );
370
+ });
371
+
372
+ test("leaves out undefined fields, as the JSON sent to Teams does", () => {
373
+ const block: JSONObject = {
374
+ type: "TextBlock",
375
+ text: "hello",
376
+ color: undefined,
377
+ };
378
+
379
+ // '{"type":"TextBlock","text":"hello"}' is 35 code units.
380
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes(block)).toBe(70);
381
+ });
382
+
383
+ test("measures a whole adaptive card as the JSON sent to Teams", () => {
384
+ const card: JSONObject = {
385
+ type: "AdaptiveCard",
386
+ version: "1.4",
387
+ body: [
388
+ { type: "TextBlock", text: `Create incident ${GRINNING_FACE}` },
389
+ {
390
+ type: "Input.ChoiceSet",
391
+ id: "incidentMonitors",
392
+ isMultiSelect: true,
393
+ choices: [
394
+ { title: "API - 東京", value: "monitor-1" },
395
+ { title: "Checkout", value: "monitor-2" },
396
+ ],
397
+ },
398
+ ],
399
+ };
400
+
401
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes(card)).toBe(
402
+ JSON.stringify(card).length * 2,
403
+ );
404
+ });
405
+ });
406
+
407
+ describe("MicrosoftTeamsMessageSize.isMessageTooLargeError", () => {
408
+ for (const errorCase of TOO_LARGE_ERRORS) {
409
+ test(`is true for ${errorCase.description}`, () => {
410
+ expect(
411
+ MicrosoftTeamsMessageSize.isMessageTooLargeError(errorCase.error),
412
+ ).toBe(true);
413
+ });
414
+ }
415
+
416
+ for (const errorCase of OTHER_ERRORS) {
417
+ test(`is false for ${errorCase.description}`, () => {
418
+ expect(
419
+ MicrosoftTeamsMessageSize.isMessageTooLargeError(errorCase.error),
420
+ ).toBe(false);
421
+ });
422
+ }
423
+
424
+ test("does not depend on instanceof: the other realm's error is no Error here", () => {
425
+ const foreignError: unknown =
426
+ buildTeamsMessageTooLargeErrorFromAnotherRealm();
427
+
428
+ expect(foreignError instanceof Error).toBe(false);
429
+ expect(MicrosoftTeamsMessageSize.isMessageTooLargeError(foreignError)).toBe(
430
+ true,
431
+ );
432
+ });
433
+ });
434
+
435
+ describe("MicrosoftTeamsMessageSize.getErrorStatusCode", () => {
436
+ test("reads statusCode", () => {
437
+ expect(
438
+ MicrosoftTeamsMessageSize.getErrorStatusCode(
439
+ buildTeamsMessageTooLargeError(),
440
+ ),
441
+ ).toBe(413);
442
+ });
443
+
444
+ test("prefers statusCode over response.status", () => {
445
+ expect(
446
+ MicrosoftTeamsMessageSize.getErrorStatusCode({
447
+ statusCode: 502,
448
+ response: { status: 413 },
449
+ }),
450
+ ).toBe(502);
451
+ });
452
+
453
+ test("falls back to response.status, enumerable or not", () => {
454
+ expect(
455
+ MicrosoftTeamsMessageSize.getErrorStatusCode({
456
+ response: { status: 403 },
457
+ }),
458
+ ).toBe(403);
459
+ expect(
460
+ MicrosoftTeamsMessageSize.getErrorStatusCode(
461
+ buildRestError({ message: "Forbidden", responseStatus: 403 }),
462
+ ),
463
+ ).toBe(403);
464
+ });
465
+
466
+ test("ignores a statusCode that is not a number and reads response.status", () => {
467
+ expect(
468
+ MicrosoftTeamsMessageSize.getErrorStatusCode({
469
+ statusCode: "413",
470
+ response: { status: 404 },
471
+ }),
472
+ ).toBe(404);
473
+ expect(
474
+ MicrosoftTeamsMessageSize.getErrorStatusCode({
475
+ statusCode: null,
476
+ response: { status: 404 },
477
+ }),
478
+ ).toBe(404);
479
+ });
480
+
481
+ test("is undefined when no field holds a number", () => {
482
+ expect(
483
+ MicrosoftTeamsMessageSize.getErrorStatusCode({
484
+ statusCode: "413",
485
+ response: { status: "413" },
486
+ }),
487
+ ).toBeUndefined();
488
+ expect(
489
+ MicrosoftTeamsMessageSize.getErrorStatusCode({ response: null }),
490
+ ).toBeUndefined();
491
+ expect(
492
+ MicrosoftTeamsMessageSize.getErrorStatusCode({ response: "413" }),
493
+ ).toBeUndefined();
494
+ expect(MicrosoftTeamsMessageSize.getErrorStatusCode({})).toBeUndefined();
495
+ });
496
+
497
+ test("does not read a OneUptime exception's numeric code as a status", () => {
498
+ expect(
499
+ MicrosoftTeamsMessageSize.getErrorStatusCode(
500
+ new BadDataException("Title is required."),
501
+ ),
502
+ ).toBeUndefined();
503
+ });
504
+
505
+ test("is undefined for anything that is not an object", () => {
506
+ expect(MicrosoftTeamsMessageSize.getErrorStatusCode(null)).toBeUndefined();
507
+ expect(
508
+ MicrosoftTeamsMessageSize.getErrorStatusCode(undefined),
509
+ ).toBeUndefined();
510
+ expect(MicrosoftTeamsMessageSize.getErrorStatusCode(413)).toBeUndefined();
511
+ expect(MicrosoftTeamsMessageSize.getErrorStatusCode("413")).toBeUndefined();
512
+ expect(MicrosoftTeamsMessageSize.getErrorStatusCode(true)).toBeUndefined();
513
+ });
514
+ });
515
+
516
+ describe("MicrosoftTeamsMessageSize.getErrorCode", () => {
517
+ test("reads code", () => {
518
+ expect(
519
+ MicrosoftTeamsMessageSize.getErrorCode(buildTeamsMessageTooLargeError()),
520
+ ).toBe("MessageSizeTooBig");
521
+ });
522
+
523
+ test("prefers code over details.error.code", () => {
524
+ expect(
525
+ MicrosoftTeamsMessageSize.getErrorCode({
526
+ code: "BotNotInConversationRoster",
527
+ details: { error: { code: "MessageSizeTooBig" } },
528
+ }),
529
+ ).toBe("BotNotInConversationRoster");
530
+ });
531
+
532
+ test("falls back to details.error.code when code is missing, empty or not a string", () => {
533
+ expect(
534
+ MicrosoftTeamsMessageSize.getErrorCode({
535
+ details: { error: { code: "MessageSizeTooBig" } },
536
+ }),
537
+ ).toBe("MessageSizeTooBig");
538
+ expect(
539
+ MicrosoftTeamsMessageSize.getErrorCode({
540
+ code: "",
541
+ details: { error: { code: "MessageSizeTooBig" } },
542
+ }),
543
+ ).toBe("MessageSizeTooBig");
544
+ expect(
545
+ MicrosoftTeamsMessageSize.getErrorCode({
546
+ code: 413,
547
+ details: { error: { code: "MessageSizeTooBig" } },
548
+ }),
549
+ ).toBe("MessageSizeTooBig");
550
+ });
551
+
552
+ test("is undefined when no field holds a non-empty string", () => {
553
+ expect(
554
+ MicrosoftTeamsMessageSize.getErrorCode({
555
+ code: 413,
556
+ details: { error: { code: 413 } },
557
+ }),
558
+ ).toBeUndefined();
559
+ expect(
560
+ MicrosoftTeamsMessageSize.getErrorCode({
561
+ code: "",
562
+ details: { error: { code: "" } },
563
+ }),
564
+ ).toBeUndefined();
565
+ expect(
566
+ MicrosoftTeamsMessageSize.getErrorCode({ details: null }),
567
+ ).toBeUndefined();
568
+ expect(
569
+ MicrosoftTeamsMessageSize.getErrorCode({ details: { error: null } }),
570
+ ).toBeUndefined();
571
+ expect(
572
+ MicrosoftTeamsMessageSize.getErrorCode({ details: "MessageSizeTooBig" }),
573
+ ).toBeUndefined();
574
+ expect(MicrosoftTeamsMessageSize.getErrorCode({})).toBeUndefined();
575
+ });
576
+
577
+ test("reads the code of a Node system error", () => {
578
+ expect(
579
+ MicrosoftTeamsMessageSize.getErrorCode(
580
+ Object.assign(new Error("socket hang up"), { code: "ECONNRESET" }),
581
+ ),
582
+ ).toBe("ECONNRESET");
583
+ });
584
+
585
+ test("does not read a OneUptime exception's numeric code as a Bot Framework code", () => {
586
+ expect(
587
+ MicrosoftTeamsMessageSize.getErrorCode(
588
+ new BadDataException("Title is required."),
589
+ ),
590
+ ).toBeUndefined();
591
+ });
592
+
593
+ test("is undefined for anything that is not an object", () => {
594
+ expect(MicrosoftTeamsMessageSize.getErrorCode(null)).toBeUndefined();
595
+ expect(MicrosoftTeamsMessageSize.getErrorCode(undefined)).toBeUndefined();
596
+ expect(
597
+ MicrosoftTeamsMessageSize.getErrorCode("MessageSizeTooBig"),
598
+ ).toBeUndefined();
599
+ expect(MicrosoftTeamsMessageSize.getErrorCode(413)).toBeUndefined();
600
+ });
601
+ });
602
+
603
+ describe("Microsoft Teams size budgets", () => {
604
+ test("a card is tried at 40 KiB, then 20 KiB, then with every optional list left off", () => {
605
+ expect(MICROSOFT_TEAMS_CARD_SIZE_BUDGETS_IN_BYTES).toEqual([
606
+ 40 * 1024,
607
+ 20 * 1024,
608
+ 0,
609
+ ]);
610
+ });
611
+
612
+ test("each card budget is smaller than the one before and within what Teams recommends", () => {
613
+ MICROSOFT_TEAMS_CARD_SIZE_BUDGETS_IN_BYTES.forEach(
614
+ (budgetInBytes: number, index: number) => {
615
+ expect(budgetInBytes).toBeLessThanOrEqual(
616
+ TEAMS_RECOMMENDED_MAX_MESSAGE_BYTES,
617
+ );
618
+
619
+ if (index > 0) {
620
+ expect(budgetInBytes).toBeLessThan(
621
+ MICROSOFT_TEAMS_CARD_SIZE_BUDGETS_IN_BYTES[index - 1] as number,
622
+ );
623
+ }
624
+ },
625
+ );
626
+ });
627
+
628
+ test("a text reply is kept to 40 KiB, within what Teams recommends", () => {
629
+ expect(MICROSOFT_TEAMS_TEXT_MESSAGE_BUDGET_IN_BYTES).toBe(40 * 1024);
630
+ expect(MICROSOFT_TEAMS_TEXT_MESSAGE_BUDGET_IN_BYTES).toBeLessThanOrEqual(
631
+ TEAMS_RECOMMENDED_MAX_MESSAGE_BYTES,
632
+ );
633
+ });
634
+ });
635
+
636
+ describe("MicrosoftTeamsMessageSize.fitTextToBudget", () => {
637
+ // Code units the default budget holds.
638
+ const DEFAULT_BUDGET_IN_UNITS: number =
639
+ MICROSOFT_TEAMS_TEXT_MESSAGE_BUDGET_IN_BYTES / 2;
640
+
641
+ // A reply like "show active incidents" for a big project: one line each.
642
+ function buildListReply(lineCount: number): string {
643
+ const lines: Array<string> = [];
644
+
645
+ for (let index: number = 0; index < lineCount; index++) {
646
+ lines.push(`- Monitor ${String(index).padStart(4, "0")} is operational`);
647
+ }
648
+
649
+ return lines.join("\n");
650
+ }
651
+
652
+ test("returns a text within the budget as it is", () => {
653
+ const text: string = "**Active incidents**\n\n- API is down\n- DB is slow";
654
+
655
+ expect(MicrosoftTeamsMessageSize.fitTextToBudget({ text: text })).toBe(
656
+ text,
657
+ );
658
+ expect(MicrosoftTeamsMessageSize.fitTextToBudget({ text: "" })).toBe("");
659
+ });
660
+
661
+ test("returns a text exactly the size of the default budget as it is", () => {
662
+ const text: string = "x".repeat(DEFAULT_BUDGET_IN_UNITS);
663
+
664
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes(text)).toBe(
665
+ MICROSOFT_TEAMS_TEXT_MESSAGE_BUDGET_IN_BYTES,
666
+ );
667
+ expect(MicrosoftTeamsMessageSize.fitTextToBudget({ text: text })).toBe(
668
+ text,
669
+ );
670
+ });
671
+
672
+ test("shortens a text one code unit over the default budget to exactly the budget", () => {
673
+ const text: string = "x".repeat(DEFAULT_BUDGET_IN_UNITS + 1);
674
+
675
+ const fitted: string = MicrosoftTeamsMessageSize.fitTextToBudget({
676
+ text: text,
677
+ });
678
+
679
+ expect(fitted).toBe(
680
+ "x".repeat(DEFAULT_BUDGET_IN_UNITS - DEFAULT_TRUNCATION_NOTE.length) +
681
+ DEFAULT_TRUNCATION_NOTE,
682
+ );
683
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes(fitted)).toBe(
684
+ MICROSOFT_TEAMS_TEXT_MESSAGE_BUDGET_IN_BYTES,
685
+ );
686
+ });
687
+
688
+ test("treats an explicit undefined budget and note as the defaults", () => {
689
+ const withinBudget: string = "x".repeat(DEFAULT_BUDGET_IN_UNITS);
690
+ const overBudget: string = "x".repeat(DEFAULT_BUDGET_IN_UNITS + 1);
691
+
692
+ expect(
693
+ MicrosoftTeamsMessageSize.fitTextToBudget({
694
+ text: withinBudget,
695
+ budgetInBytes: undefined,
696
+ truncationNote: undefined,
697
+ }),
698
+ ).toBe(withinBudget);
699
+ expect(
700
+ MicrosoftTeamsMessageSize.fitTextToBudget({
701
+ text: overBudget,
702
+ budgetInBytes: undefined,
703
+ truncationNote: undefined,
704
+ }),
705
+ ).toBe(MicrosoftTeamsMessageSize.fitTextToBudget({ text: overBudget }));
706
+ });
707
+
708
+ test("cuts a long reply at the last whole line that fits, then adds the default note", () => {
709
+ const text: string = buildListReply(3000);
710
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes(text)).toBeGreaterThan(
711
+ MICROSOFT_TEAMS_TEXT_MESSAGE_BUDGET_IN_BYTES,
712
+ );
713
+
714
+ const fitted: string = MicrosoftTeamsMessageSize.fitTextToBudget({
715
+ text: text,
716
+ });
717
+
718
+ expect(
719
+ MicrosoftTeamsMessageSize.getSizeInBytes(fitted),
720
+ ).toBeLessThanOrEqual(MICROSOFT_TEAMS_TEXT_MESSAGE_BUDGET_IN_BYTES);
721
+ expect(fitted.endsWith(DEFAULT_TRUNCATION_NOTE)).toBe(true);
722
+
723
+ const kept: string = fitted.substring(
724
+ 0,
725
+ fitted.length - DEFAULT_TRUNCATION_NOTE.length,
726
+ );
727
+
728
+ // A prefix of the reply that ends exactly where a line ends.
729
+ expect(text.startsWith(kept)).toBe(true);
730
+ expect(text.charAt(kept.length)).toBe("\n");
731
+ expect(kept.endsWith("is operational")).toBe(true);
732
+
733
+ // The longest such prefix: the next whole line would not have fitted.
734
+ const nextLineEnd: number = text.indexOf("\n", kept.length + 1);
735
+ expect(nextLineEnd + DEFAULT_TRUNCATION_NOTE.length).toBeGreaterThan(
736
+ DEFAULT_BUDGET_IN_UNITS,
737
+ );
738
+ });
739
+
740
+ test("honours a custom budget and note", () => {
741
+ const lines: Array<string> = [];
742
+ for (let index: number = 1; index <= 30; index++) {
743
+ lines.push(`line ${String(index).padStart(2, "0")}`);
744
+ }
745
+ const text: string = lines.join("\n");
746
+
747
+ /*
748
+ * 200 bytes hold 100 code units: 99 for the text and one for the note.
749
+ * The first 99 units end inside "line 13", so the cut falls after
750
+ * "line 12".
751
+ */
752
+ const fitted: string = MicrosoftTeamsMessageSize.fitTextToBudget({
753
+ text: text,
754
+ budgetInBytes: 200,
755
+ truncationNote: "…",
756
+ });
757
+
758
+ expect(fitted).toBe(lines.slice(0, 12).join("\n") + "…");
759
+ expect(fitted).not.toContain("_This reply was shortened");
760
+ expect(
761
+ MicrosoftTeamsMessageSize.getSizeInBytes(fitted),
762
+ ).toBeLessThanOrEqual(200);
763
+ });
764
+
765
+ test("keeps a text exactly the size of a custom budget as it is", () => {
766
+ const text: string = GRINNING_FACE.repeat(25);
767
+
768
+ expect(
769
+ MicrosoftTeamsMessageSize.fitTextToBudget({
770
+ text: text,
771
+ budgetInBytes: 100,
772
+ }),
773
+ ).toBe(text);
774
+ });
775
+
776
+ test("reads an explicit budget of 0 as no room at all, not as the default", () => {
777
+ expect(
778
+ MicrosoftTeamsMessageSize.fitTextToBudget({ text: "", budgetInBytes: 0 }),
779
+ ).toBe("");
780
+ // With no note either, nothing of the text is kept.
781
+ expect(
782
+ MicrosoftTeamsMessageSize.fitTextToBudget({
783
+ text: "hello",
784
+ budgetInBytes: 0,
785
+ truncationNote: "",
786
+ }),
787
+ ).toBe("");
788
+ });
789
+
790
+ test("rounds an odd budget down to whole code units", () => {
791
+ // 101 bytes hold 50 code units.
792
+ expect(
793
+ MicrosoftTeamsMessageSize.fitTextToBudget({
794
+ text: "x".repeat(50),
795
+ budgetInBytes: 101,
796
+ truncationNote: "…",
797
+ }),
798
+ ).toBe("x".repeat(50));
799
+
800
+ const fitted: string = MicrosoftTeamsMessageSize.fitTextToBudget({
801
+ text: "x".repeat(51),
802
+ budgetInBytes: 101,
803
+ truncationNote: "…",
804
+ });
805
+
806
+ expect(fitted).toBe("x".repeat(49) + "…");
807
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes(fitted)).toBe(100);
808
+ });
809
+
810
+ test("cuts a single line longer than the budget where the budget ends", () => {
811
+ expect(
812
+ MicrosoftTeamsMessageSize.fitTextToBudget({
813
+ text: "x".repeat(1000),
814
+ budgetInBytes: 100,
815
+ truncationNote: "…",
816
+ }),
817
+ ).toBe("x".repeat(49) + "…");
818
+ });
819
+
820
+ test("cuts a first line longer than the budget where the budget ends, whatever line breaks follow", () => {
821
+ // Only a line break within the part that fits can be cut at.
822
+ expect(
823
+ MicrosoftTeamsMessageSize.fitTextToBudget({
824
+ text: "x".repeat(60) + "\nsecond line\nthird line",
825
+ budgetInBytes: 100,
826
+ truncationNote: "…",
827
+ }),
828
+ ).toBe("x".repeat(49) + "…");
829
+ });
830
+
831
+ /*
832
+ * Pins the fix for a bug reported with these tests. The cut used to look
833
+ * for a line break only in the code units before the cut, so a line break
834
+ * sitting exactly at the cut was not seen, and the whole line before it,
835
+ * which fits, was dropped as well: at the default budget, a "show active
836
+ * incidents" style reply lost a line for nothing whenever a line happened
837
+ * to end right at the budget. The line break is now looked for up to and
838
+ * including the cut.
839
+ */
840
+ test("keeps a whole line that ends exactly where the budget ends", () => {
841
+ // Code units the default budget leaves for the text before the note.
842
+ const unitsForText: number =
843
+ DEFAULT_BUDGET_IN_UNITS - DEFAULT_TRUNCATION_NOTE.length;
844
+
845
+ /*
846
+ * A 20-unit header and its line break, then 30-unit lines (29 characters
847
+ * and a line break): the line break after line 678 sits at unit 20390,
848
+ * exactly where the budget ends.
849
+ */
850
+ const reply: string = "**Active incidents**\n" + buildListReply(3000);
851
+ expect(reply.charAt(unitsForText)).toBe("\n");
852
+ expect(reply.substring(unitsForText - 29, unitsForText)).toBe(
853
+ "- Monitor 0678 is operational",
854
+ );
855
+
856
+ const fitted: string = MicrosoftTeamsMessageSize.fitTextToBudget({
857
+ text: reply,
858
+ });
859
+ const kept: string = fitted.substring(
860
+ 0,
861
+ fitted.length - DEFAULT_TRUNCATION_NOTE.length,
862
+ );
863
+
864
+ // Line 678 fits, so it is the last line kept.
865
+ expect(kept.substring(kept.length - 29)).toBe(
866
+ "- Monitor 0678 is operational",
867
+ );
868
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes(fitted)).toBe(
869
+ MICROSOFT_TEAMS_TEXT_MESSAGE_BUDGET_IN_BYTES,
870
+ );
871
+
872
+ // The same at a small budget: 40 bytes hold 19 units of text and the note.
873
+ expect(
874
+ MicrosoftTeamsMessageSize.fitTextToBudget({
875
+ text: "abcdefghi\nabcdefghi\nzzzzzzzzzz",
876
+ budgetInBytes: 40,
877
+ truncationNote: "…",
878
+ }),
879
+ ).toBe("abcdefghi\nabcdefghi…");
880
+
881
+ // With Windows line endings: the "\r" before the cut goes, the line stays.
882
+ expect(
883
+ MicrosoftTeamsMessageSize.fitTextToBudget({
884
+ text: "abcdefgh\r\nabcdefgh\r\nzzzz",
885
+ budgetInBytes: 40,
886
+ truncationNote: "…",
887
+ }),
888
+ ).toBe("abcdefgh\r\nabcdefgh…");
889
+
890
+ /*
891
+ * And at every budget, over lines of 1 to 13 letters: once the first line
892
+ * fits, the reply is cut at a line break, and the line after the cut
893
+ * would not have fitted with the note. Before that, the first line is cut
894
+ * where the budget ends.
895
+ */
896
+ const lines: Array<string> = [];
897
+ for (let index: number = 0; index < 40; index++) {
898
+ lines.push("abcdefghijklm".substring(0, 1 + ((index * 7 + 12) % 13)));
899
+ }
900
+ const text: string = lines.join("\n");
901
+ const firstLineEnd: number = text.indexOf("\n");
902
+ const violations: Array<string> = [];
903
+
904
+ for (
905
+ let budgetInBytes: number = 2;
906
+ budgetInBytes < MicrosoftTeamsMessageSize.getSizeInBytes(text);
907
+ budgetInBytes++
908
+ ) {
909
+ // Code units this budget leaves for the text before the one-unit note.
910
+ const unitsLeft: number = Math.floor(budgetInBytes / 2) - 1;
911
+ const shortened: string = MicrosoftTeamsMessageSize.fitTextToBudget({
912
+ text: text,
913
+ budgetInBytes: budgetInBytes,
914
+ truncationNote: "…",
915
+ });
916
+ const keptText: string = shortened.substring(0, shortened.length - 1);
917
+
918
+ if (
919
+ !shortened.endsWith("…") ||
920
+ MicrosoftTeamsMessageSize.getSizeInBytes(shortened) > budgetInBytes
921
+ ) {
922
+ violations.push(
923
+ `budget ${budgetInBytes}: ${JSON.stringify(shortened)}`,
924
+ );
925
+ continue;
926
+ }
927
+
928
+ if (firstLineEnd > unitsLeft) {
929
+ if (keptText !== text.substring(0, unitsLeft)) {
930
+ violations.push(
931
+ `budget ${budgetInBytes}: the first line was not cut where the budget ends`,
932
+ );
933
+ }
934
+ continue;
935
+ }
936
+
937
+ if (!text.startsWith(keptText) || text.charAt(keptText.length) !== "\n") {
938
+ violations.push(`budget ${budgetInBytes}: cut inside a line`);
939
+ continue;
940
+ }
941
+
942
+ const nextLineEnd: number = text.indexOf("\n", keptText.length + 1);
943
+ if ((nextLineEnd === -1 ? text.length : nextLineEnd) <= unitsLeft) {
944
+ violations.push(
945
+ `budget ${budgetInBytes}: dropped ${JSON.stringify(
946
+ text.substring(keptText.length + 1, nextLineEnd),
947
+ )}, which fits`,
948
+ );
949
+ }
950
+ }
951
+
952
+ expect(violations).toEqual([]);
953
+ });
954
+
955
+ test("holds its promises for any mix of text, budget and note", () => {
956
+ /*
957
+ * A seeded sweep over replies made of ASCII, accented and CJK letters,
958
+ * emoji (surrogate pairs), spaces, tabs and both kinds of line break, with
959
+ * budgets on both sides of the text's size. For every one: a text within
960
+ * the budget comes back as it is; a longer one comes back within the
961
+ * budget as a prefix of the text, without trailing whitespace, followed
962
+ * by the note; never with half an emoji; cut at a line break whenever the
963
+ * part that fits has one; and fitting it again changes nothing.
964
+ */
965
+ const random: () => number = createSeededRandom(4111);
966
+ const pieces: Array<string> = [
967
+ "a",
968
+ "Z",
969
+ "7",
970
+ " ",
971
+ "\t",
972
+ "\n",
973
+ "\r\n",
974
+ "é",
975
+ "漢",
976
+ "字",
977
+ GRINNING_FACE,
978
+ "👍🏽",
979
+ "- Monitor 0042 is operational",
980
+ "**Active incidents**",
981
+ ];
982
+ const notes: Array<string | undefined> = [undefined, "…", "\n[shortened]"];
983
+ const violations: Array<string> = [];
984
+ let shortenedCount: number = 0;
985
+ let lineBreakCutCount: number = 0;
986
+
987
+ for (let caseIndex: number = 0; caseIndex < 400; caseIndex++) {
988
+ let text: string = "";
989
+ const pieceCount: number = Math.floor(random() * 700);
990
+
991
+ for (let index: number = 0; index < pieceCount; index++) {
992
+ text += pieces[Math.floor(random() * pieces.length)] as string;
993
+ }
994
+
995
+ const truncationNote: string | undefined =
996
+ notes[Math.floor(random() * notes.length)];
997
+ const note: string = truncationNote ?? DEFAULT_TRUNCATION_NOTE;
998
+ const textSizeInBytes: number =
999
+ MicrosoftTeamsMessageSize.getSizeInBytes(text);
1000
+ /*
1001
+ * A quarter of the budgets hold the whole text; the others range from
1002
+ * the smallest budget the note fits in upwards.
1003
+ */
1004
+ const budgetInBytes: number =
1005
+ random() < 0.25
1006
+ ? Math.max(textSizeInBytes, note.length * 2) +
1007
+ Math.floor(random() * 40)
1008
+ : note.length * 2 + Math.floor(random() * textSizeInBytes);
1009
+
1010
+ const fitted: string = MicrosoftTeamsMessageSize.fitTextToBudget({
1011
+ text: text,
1012
+ budgetInBytes: budgetInBytes,
1013
+ truncationNote: truncationNote,
1014
+ });
1015
+
1016
+ const report: (rule: string) => void = (rule: string): void => {
1017
+ violations.push(
1018
+ `case ${caseIndex} (budget ${budgetInBytes}, note ${JSON.stringify(
1019
+ note,
1020
+ )}): ${rule}; text starts ${JSON.stringify(text.substring(0, 60))}`,
1021
+ );
1022
+ };
1023
+
1024
+ if (textSizeInBytes <= budgetInBytes) {
1025
+ if (fitted !== text) {
1026
+ report("a text within the budget was changed");
1027
+ }
1028
+ continue;
1029
+ }
1030
+
1031
+ shortenedCount++;
1032
+ const kept: string = fitted.substring(0, fitted.length - note.length);
1033
+
1034
+ if (MicrosoftTeamsMessageSize.getSizeInBytes(fitted) > budgetInBytes) {
1035
+ report("the result is over the budget");
1036
+ }
1037
+
1038
+ if (!fitted.endsWith(note)) {
1039
+ report("the result does not end with the note");
1040
+ }
1041
+
1042
+ if (!text.startsWith(kept)) {
1043
+ report("the kept part is not a prefix of the text");
1044
+ }
1045
+
1046
+ if (kept !== kept.trimEnd()) {
1047
+ report("the kept part ends with whitespace");
1048
+ }
1049
+
1050
+ if (!WELL_FORMED_UTF16.test(fitted)) {
1051
+ report("the result has half of a surrogate pair");
1052
+ }
1053
+
1054
+ const unitsForText: number = Math.floor(budgetInBytes / 2) - note.length;
1055
+
1056
+ if (text.substring(0, unitsForText).lastIndexOf("\n") > 0) {
1057
+ lineBreakCutCount++;
1058
+
1059
+ // At the cut the text goes on with the line break (or blanks before it).
1060
+ if (!STARTS_WITH_WHITESPACE.test(text.substring(kept.length))) {
1061
+ report("the text was cut inside a line");
1062
+ }
1063
+ }
1064
+
1065
+ if (
1066
+ MicrosoftTeamsMessageSize.fitTextToBudget({
1067
+ text: fitted,
1068
+ budgetInBytes: budgetInBytes,
1069
+ truncationNote: truncationNote,
1070
+ }) !== fitted
1071
+ ) {
1072
+ report("fitting the result again changed it");
1073
+ }
1074
+ }
1075
+
1076
+ expect(violations).toEqual([]);
1077
+ // The sweep went down every path: kept as it is, cut, cut at a line break.
1078
+ expect(shortenedCount).toBeGreaterThan(150);
1079
+ expect(400 - shortenedCount).toBeGreaterThan(50);
1080
+ expect(lineBreakCutCount).toBeGreaterThan(100);
1081
+ });
1082
+
1083
+ test("cuts CJK text by code units", () => {
1084
+ const fitted: string = MicrosoftTeamsMessageSize.fitTextToBudget({
1085
+ text: "漢".repeat(100),
1086
+ budgetInBytes: 42,
1087
+ truncationNote: "…",
1088
+ });
1089
+
1090
+ expect(fitted).toBe("漢".repeat(20) + "…");
1091
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes(fitted)).toBe(42);
1092
+ });
1093
+
1094
+ test("never splits an emoji when the cut falls between its two halves", () => {
1095
+ /*
1096
+ * 100 bytes hold 50 code units, 49 for the text: 48 letters and the first
1097
+ * half of the emoji. That half is dropped.
1098
+ */
1099
+ const text: string = "a".repeat(48) + GRINNING_FACE.repeat(10);
1100
+
1101
+ const fitted: string = MicrosoftTeamsMessageSize.fitTextToBudget({
1102
+ text: text,
1103
+ budgetInBytes: 100,
1104
+ truncationNote: "…",
1105
+ });
1106
+
1107
+ expect(fitted).toBe("a".repeat(48) + "…");
1108
+ expect(fitted).not.toContain(GRINNING_FACE_HIGH_SURROGATE);
1109
+ expect(fitted).toMatch(WELL_FORMED_UTF16);
1110
+ });
1111
+
1112
+ test("keeps an emoji whose two halves both fit", () => {
1113
+ const text: string = "a".repeat(47) + GRINNING_FACE.repeat(10);
1114
+
1115
+ const fitted: string = MicrosoftTeamsMessageSize.fitTextToBudget({
1116
+ text: text,
1117
+ budgetInBytes: 100,
1118
+ truncationNote: "…",
1119
+ });
1120
+
1121
+ expect(fitted).toBe("a".repeat(47) + GRINNING_FACE + "…");
1122
+ expect(fitted).toMatch(WELL_FORMED_UTF16);
1123
+ });
1124
+
1125
+ test("cuts a text of nothing but emoji between whole emoji", () => {
1126
+ const text: string = GRINNING_FACE.repeat(1000);
1127
+
1128
+ // 11 units for the text: five emoji and half of the sixth.
1129
+ const odd: string = MicrosoftTeamsMessageSize.fitTextToBudget({
1130
+ text: text,
1131
+ budgetInBytes: 24,
1132
+ truncationNote: "…",
1133
+ });
1134
+ // 10 units for the text: exactly five emoji.
1135
+ const even: string = MicrosoftTeamsMessageSize.fitTextToBudget({
1136
+ text: text,
1137
+ budgetInBytes: 22,
1138
+ truncationNote: "…",
1139
+ });
1140
+
1141
+ expect(odd).toBe(GRINNING_FACE.repeat(5) + "…");
1142
+ expect(even).toBe(GRINNING_FACE.repeat(5) + "…");
1143
+ expect(odd).toMatch(WELL_FORMED_UTF16);
1144
+ expect(even).toMatch(WELL_FORMED_UTF16);
1145
+ });
1146
+
1147
+ test("never splits an emoji with the default note either", () => {
1148
+ const text: string =
1149
+ "a".repeat(DEFAULT_BUDGET_IN_UNITS - DEFAULT_TRUNCATION_NOTE.length - 1) +
1150
+ GRINNING_FACE.repeat(100);
1151
+
1152
+ const fitted: string = MicrosoftTeamsMessageSize.fitTextToBudget({
1153
+ text: text,
1154
+ });
1155
+
1156
+ expect(fitted).toBe(
1157
+ "a".repeat(DEFAULT_BUDGET_IN_UNITS - DEFAULT_TRUNCATION_NOTE.length - 1) +
1158
+ DEFAULT_TRUNCATION_NOTE,
1159
+ );
1160
+ expect(fitted).toMatch(WELL_FORMED_UTF16);
1161
+ });
1162
+
1163
+ test("drops trailing whitespace and blank lines before the note", () => {
1164
+ const text: string = "first line \r\n\r\n\r\n" + "y".repeat(500);
1165
+
1166
+ expect(
1167
+ MicrosoftTeamsMessageSize.fitTextToBudget({
1168
+ text: text,
1169
+ budgetInBytes: 100,
1170
+ truncationNote: "…",
1171
+ }),
1172
+ ).toBe("first line…");
1173
+ });
1174
+
1175
+ test("does not cut at a line break at the very start, which would keep nothing", () => {
1176
+ expect(
1177
+ MicrosoftTeamsMessageSize.fitTextToBudget({
1178
+ text: "\n" + "x".repeat(300),
1179
+ budgetInBytes: 100,
1180
+ truncationNote: "…",
1181
+ }),
1182
+ ).toBe("\n" + "x".repeat(48) + "…");
1183
+ });
1184
+
1185
+ test("gives the whole budget to the text when the note does not fit", () => {
1186
+ /*
1187
+ * A reply over the budget is one Teams may refuse, so a note that does
1188
+ * not fit is left off rather than sent whole. No caller passes a budget
1189
+ * this small: the text replies use the 40 KiB default, which the note
1190
+ * always fits in.
1191
+ */
1192
+ // 10 bytes hold 5 code units, and the default note is far longer.
1193
+ const withoutDefaultNote: string =
1194
+ MicrosoftTeamsMessageSize.fitTextToBudget({
1195
+ text: "x".repeat(100),
1196
+ budgetInBytes: 10,
1197
+ });
1198
+
1199
+ expect(withoutDefaultNote).toBe("xxxxx");
1200
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes(withoutDefaultNote)).toBe(
1201
+ 10,
1202
+ );
1203
+
1204
+ // 4 bytes hold 2 code units, and "…more" needs 5.
1205
+ expect(
1206
+ MicrosoftTeamsMessageSize.fitTextToBudget({
1207
+ text: "x".repeat(100),
1208
+ budgetInBytes: 4,
1209
+ truncationNote: "…more",
1210
+ }),
1211
+ ).toBe("xx");
1212
+ });
1213
+
1214
+ test("adds a note that fits exactly, although it leaves no room for the text", () => {
1215
+ // 10 bytes hold 5 code units: "[cut]" fits, with nothing left over.
1216
+ expect(
1217
+ MicrosoftTeamsMessageSize.fitTextToBudget({
1218
+ text: "x".repeat(100),
1219
+ budgetInBytes: 10,
1220
+ truncationNote: "[cut]",
1221
+ }),
1222
+ ).toBe("[cut]");
1223
+
1224
+ // One code unit more is one for the text.
1225
+ expect(
1226
+ MicrosoftTeamsMessageSize.fitTextToBudget({
1227
+ text: "x".repeat(100),
1228
+ budgetInBytes: 12,
1229
+ truncationNote: "[cut]",
1230
+ }),
1231
+ ).toBe("x[cut]");
1232
+
1233
+ // A note one code unit longer does not fit, and the text gets all five.
1234
+ expect(
1235
+ MicrosoftTeamsMessageSize.fitTextToBudget({
1236
+ text: "x".repeat(100),
1237
+ budgetInBytes: 10,
1238
+ truncationNote: "[cut!]",
1239
+ }),
1240
+ ).toBe("xxxxx");
1241
+ });
1242
+
1243
+ test("keeps nothing, not even the note, at a budget that holds no code unit", () => {
1244
+ for (const budgetInBytes of [0, 1]) {
1245
+ expect(
1246
+ MicrosoftTeamsMessageSize.fitTextToBudget({
1247
+ text: "hello",
1248
+ budgetInBytes: budgetInBytes,
1249
+ }),
1250
+ ).toBe("");
1251
+ expect(
1252
+ MicrosoftTeamsMessageSize.fitTextToBudget({
1253
+ text: "hello",
1254
+ budgetInBytes: budgetInBytes,
1255
+ truncationNote: "…",
1256
+ }),
1257
+ ).toBe("");
1258
+ }
1259
+ });
1260
+
1261
+ test("without the note, still cuts at the last line break that fits, one right at the cut included", () => {
1262
+ const text: string = "first line\nsecond line\nthird line";
1263
+ // The line break after "second line" is code unit 22.
1264
+ expect(text.charAt(22)).toBe("\n");
1265
+
1266
+ // 44 bytes hold 22 code units: both lines, and no room for the note.
1267
+ const fitted: string = MicrosoftTeamsMessageSize.fitTextToBudget({
1268
+ text: text,
1269
+ budgetInBytes: 44,
1270
+ });
1271
+
1272
+ expect(fitted).toBe("first line\nsecond line");
1273
+ expect(MicrosoftTeamsMessageSize.getSizeInBytes(fitted)).toBe(44);
1274
+
1275
+ // One code unit less, and the second line no longer fits.
1276
+ expect(
1277
+ MicrosoftTeamsMessageSize.fitTextToBudget({
1278
+ text: text,
1279
+ budgetInBytes: 42,
1280
+ }),
1281
+ ).toBe("first line");
1282
+ });
1283
+
1284
+ test("without the note, never splits an emoji and drops blanks before the cut", () => {
1285
+ // 10 bytes hold 5 code units: four letters and the first half of an emoji.
1286
+ const fitted: string = MicrosoftTeamsMessageSize.fitTextToBudget({
1287
+ text: "aaaa" + GRINNING_FACE.repeat(10),
1288
+ budgetInBytes: 10,
1289
+ });
1290
+
1291
+ expect(fitted).toBe("aaaa");
1292
+ expect(fitted).not.toContain(GRINNING_FACE_HIGH_SURROGATE);
1293
+
1294
+ expect(
1295
+ MicrosoftTeamsMessageSize.fitTextToBudget({
1296
+ text: "ab " + "c".repeat(100),
1297
+ budgetInBytes: 10,
1298
+ }),
1299
+ ).toBe("ab");
1300
+ });
1301
+
1302
+ test("is never over the budget at any budget, and keeps as much of the text as fits", () => {
1303
+ /*
1304
+ * Every budget from nothing to past the text's size, over replies with
1305
+ * blank lines, both kinds of line break, trailing blanks, emoji and CJK,
1306
+ * with notes that fit only some of those budgets. For every one: a text
1307
+ * within the budget comes back as it is; a longer one comes back within
1308
+ * the budget, ending with the note exactly when the note fits in it, and
1309
+ * before that a prefix of the text in whole characters, without trailing
1310
+ * whitespace. What the cut dropped within reach is only blanks, the line
1311
+ * break cut at and the start of a line that did not fit - or, with no
1312
+ * line break to cut at, blanks and at most the first half of an emoji the
1313
+ * budget ends inside. Fitting the result again changes nothing.
1314
+ */
1315
+ const texts: Array<string> = [
1316
+ `**Active incidents**\n\n- API is down ${GRINNING_FACE}\n- DB is slow\r\n- 漢字 queue \n\n\n- Checkout ${GRINNING_FACE}${GRINNING_FACE}\n- Last line`,
1317
+ "x".repeat(40) + "\nshort\n" + GRINNING_FACE.repeat(10),
1318
+ GRINNING_FACE.repeat(30),
1319
+ "\n" + "abc def ".repeat(10),
1320
+ "abc\n \n\t\nxyz\n" + `${GRINNING_FACE} `.repeat(10) + "\r\n\r\nend",
1321
+ buildListReply(8),
1322
+ ];
1323
+ const notes: Array<string | undefined> = [
1324
+ undefined,
1325
+ "…",
1326
+ "\n[shortened]",
1327
+ "",
1328
+ ];
1329
+ const violations: Array<string> = [];
1330
+ let noteAddedCount: number = 0;
1331
+ let noteLeftOffCount: number = 0;
1332
+ let lineBreakCutCount: number = 0;
1333
+ let lineBreakAtTheCutCount: number = 0;
1334
+ let midLineCutCount: number = 0;
1335
+ let halfEmojiDroppedCount: number = 0;
1336
+
1337
+ texts.forEach((text: string, textIndex: number): void => {
1338
+ const textSizeInBytes: number =
1339
+ MicrosoftTeamsMessageSize.getSizeInBytes(text);
1340
+
1341
+ for (const truncationNote of notes) {
1342
+ const note: string = truncationNote ?? DEFAULT_TRUNCATION_NOTE;
1343
+
1344
+ for (
1345
+ let budgetInBytes: number = 0;
1346
+ budgetInBytes <= textSizeInBytes + 4;
1347
+ budgetInBytes++
1348
+ ) {
1349
+ const fitted: string = MicrosoftTeamsMessageSize.fitTextToBudget({
1350
+ text: text,
1351
+ budgetInBytes: budgetInBytes,
1352
+ truncationNote: truncationNote,
1353
+ });
1354
+
1355
+ const report: (rule: string) => void = (rule: string): void => {
1356
+ violations.push(
1357
+ `text ${textIndex}, budget ${budgetInBytes}, note ${JSON.stringify(
1358
+ note,
1359
+ )}: ${rule}; got ${JSON.stringify(fitted)}`,
1360
+ );
1361
+ };
1362
+
1363
+ if (textSizeInBytes <= budgetInBytes) {
1364
+ if (fitted !== text) {
1365
+ report("a text within the budget was changed");
1366
+ }
1367
+ continue;
1368
+ }
1369
+
1370
+ if (
1371
+ MicrosoftTeamsMessageSize.getSizeInBytes(fitted) > budgetInBytes
1372
+ ) {
1373
+ report("the result is over the budget");
1374
+ }
1375
+
1376
+ const unitsInBudget: number = Math.floor(budgetInBytes / 2);
1377
+ const noteFits: boolean = note.length <= unitsInBudget;
1378
+
1379
+ if (note) {
1380
+ if (noteFits) {
1381
+ noteAddedCount++;
1382
+ } else {
1383
+ noteLeftOffCount++;
1384
+ }
1385
+
1386
+ if (fitted.endsWith(note) !== noteFits) {
1387
+ report(
1388
+ noteFits
1389
+ ? "the note fits but was left off"
1390
+ : "the note does not fit but was added",
1391
+ );
1392
+ }
1393
+ }
1394
+
1395
+ const kept: string = noteFits
1396
+ ? fitted.substring(0, fitted.length - note.length)
1397
+ : fitted;
1398
+ const unitsForText: number =
1399
+ unitsInBudget - (noteFits ? note.length : 0);
1400
+
1401
+ if (!text.startsWith(kept)) {
1402
+ report("the kept part is not a prefix of the text");
1403
+ }
1404
+
1405
+ if (kept !== kept.trimEnd()) {
1406
+ report("the kept part ends with whitespace");
1407
+ }
1408
+
1409
+ if (!WELL_FORMED_UTF16.test(fitted)) {
1410
+ report("the result has half of a surrogate pair");
1411
+ }
1412
+
1413
+ if (text.lastIndexOf("\n", unitsForText) > 0) {
1414
+ lineBreakCutCount++;
1415
+
1416
+ if (text.charAt(unitsForText) === "\n") {
1417
+ lineBreakAtTheCutCount++;
1418
+ }
1419
+
1420
+ // Up to and including the last code unit the text may take.
1421
+ if (
1422
+ !DROPPED_AT_A_LINE_BREAK.test(
1423
+ text.substring(kept.length, unitsForText + 1),
1424
+ )
1425
+ ) {
1426
+ report("dropped more than the line that did not fit");
1427
+ }
1428
+ } else {
1429
+ midLineCutCount++;
1430
+ const dropped: string = text.substring(kept.length, unitsForText);
1431
+
1432
+ if (ENDS_WITH_HIGH_SURROGATE.test(dropped)) {
1433
+ halfEmojiDroppedCount++;
1434
+ }
1435
+
1436
+ if (!DROPPED_MID_LINE.test(dropped)) {
1437
+ report("dropped more than blanks and half an emoji");
1438
+ }
1439
+ }
1440
+
1441
+ if (
1442
+ MicrosoftTeamsMessageSize.fitTextToBudget({
1443
+ text: fitted,
1444
+ budgetInBytes: budgetInBytes,
1445
+ truncationNote: truncationNote,
1446
+ }) !== fitted
1447
+ ) {
1448
+ report("fitting the result again changed it");
1449
+ }
1450
+ }
1451
+ }
1452
+ });
1453
+
1454
+ expect(violations).toEqual([]);
1455
+ /*
1456
+ * The sweep went down every path, many times: note added and left off,
1457
+ * cut at a line break (one right at the cut among them) and inside a
1458
+ * line (half an emoji dropped among them).
1459
+ */
1460
+ expect(noteAddedCount).toBeGreaterThan(2000);
1461
+ expect(noteLeftOffCount).toBeGreaterThan(1000);
1462
+ expect(lineBreakCutCount).toBeGreaterThan(2000);
1463
+ expect(lineBreakAtTheCutCount).toBeGreaterThan(150);
1464
+ expect(midLineCutCount).toBeGreaterThan(2000);
1465
+ expect(halfEmojiDroppedCount).toBeGreaterThan(200);
1466
+ });
1467
+
1468
+ test("uses an explicit empty note as no note at all", () => {
1469
+ const fitted: string = MicrosoftTeamsMessageSize.fitTextToBudget({
1470
+ text: "x".repeat(100),
1471
+ budgetInBytes: 20,
1472
+ truncationNote: "",
1473
+ });
1474
+
1475
+ expect(fitted).toBe("x".repeat(10));
1476
+ });
1477
+ });