@oneuptime/common 12.0.34 → 13.0.0

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 (259) hide show
  1. package/Models/DatabaseModels/GlobalConfig.ts +12 -12
  2. package/Models/DatabaseModels/InventoryItem.ts +44 -1
  3. package/Models/DatabaseModels/NetworkDevice.ts +126 -0
  4. package/Server/EnvironmentConfig.ts +47 -16
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1791700000000-AddMacAddressToNetworkDevice.ts +25 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  7. package/Server/Infrastructure/Redis.ts +8 -8
  8. package/Server/Infrastructure/Status.ts +10 -4
  9. package/Server/Services/DatabaseService.ts +65 -13
  10. package/Server/Services/InventoryItemService.ts +31 -1
  11. package/Server/Services/MonitorTemplateService.ts +140 -19
  12. package/Server/Services/NetworkDeviceAutoImportRuleEngineService.ts +349 -41
  13. package/Server/Services/NetworkDeviceDiscoveryScanService.ts +87 -2
  14. package/Server/Services/NetworkDeviceService.ts +69 -0
  15. package/Server/Services/UserNotificationRuleService.ts +76 -25
  16. package/Server/Types/Database/Permissions/ReadPermission.ts +45 -5
  17. package/Server/Types/Database/QueryUtil.ts +56 -0
  18. package/Server/Types/Markdown.ts +93 -1
  19. package/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.ts +82 -17
  20. package/Server/Utils/EmailRollup/EmailRollupFlushRunner.ts +10 -1
  21. package/Server/Utils/EmailRollup/EmailRollupRenderer.ts +2 -2
  22. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +82 -35
  23. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +31 -5
  24. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +202 -37
  25. package/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.ts +82 -9
  26. package/Server/Utils/Monitor/MonitorCriteriaMessageBuilder.ts +16 -7
  27. package/Server/Utils/Monitor/MonitorCriteriaMessageFormatter.ts +29 -17
  28. package/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.ts +107 -48
  29. package/Server/Utils/Monitor/NetworkDeviceMacLearningUtil.ts +158 -0
  30. package/Server/Utils/Monitor/NetworkInventoryUtil.ts +27 -0
  31. package/Server/Utils/Telemetry/EntityRegistry.ts +11 -1
  32. package/Server/Utils/Telemetry/TelemetryEntity.ts +83 -9
  33. package/Tests/App/Dashboard/AddNeighborToMonitoringModal.test.tsx +32 -0
  34. package/Tests/App/Dashboard/AdoptedDeviceDrawer.test.tsx +374 -0
  35. package/Tests/App/Dashboard/DashboardLabelVariables.test.tsx +580 -0
  36. package/Tests/App/Dashboard/DashboardMonitorLabelVariable.test.tsx +438 -0
  37. package/Tests/App/Dashboard/DashboardVariableAllContract.test.tsx +5 -2
  38. package/Tests/App/Dashboard/DeviceAttachmentCard.test.tsx +273 -0
  39. package/Tests/App/Dashboard/DiscoveryReviewInventoryRefresh.test.tsx +505 -0
  40. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +163 -0
  41. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +401 -0
  42. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +232 -0
  43. package/Tests/App/Dashboard/InventoryItems.test.tsx +201 -0
  44. package/Tests/App/Dashboard/InventoryTypeAndStatusFacets.test.tsx +798 -0
  45. package/Tests/App/Dashboard/NetworkTopologyLiveView.test.tsx +320 -0
  46. package/Tests/App/Dashboard/NetworkTopologyToolbar.test.tsx +275 -0
  47. package/Tests/App/Dashboard/OnCallCalendarFeedEmptyState.test.tsx +975 -0
  48. package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +63 -0
  49. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +626 -0
  50. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +348 -0
  51. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +331 -0
  52. package/Tests/App/Dashboard/UserSettingsEmailPreferences.test.tsx +603 -0
  53. package/Tests/App/Dashboard/UserSettingsNotificationSettings.test.tsx +324 -0
  54. package/Tests/App/PublicDashboard/DashboardVariableSelector.test.tsx +13 -4
  55. package/Tests/App/StatusPage/StatusPageOidcOrigin.test.tsx +223 -0
  56. package/Tests/Models/InventoryItemStatus.test.ts +147 -0
  57. package/Tests/Models/NetworkDeviceMacAddressColumn.test.ts +294 -0
  58. package/Tests/Server/API/DashboardPublicResourceListAPI.test.ts +129 -0
  59. package/Tests/Server/EnvironmentConfigValkey.test.ts +227 -0
  60. package/Tests/Server/Infrastructure/Postgres/AddMacAddressToNetworkDeviceMigration.test.ts +365 -0
  61. package/Tests/Server/Infrastructure/Postgres/CustomFieldValueMappingMigration.test.ts +62 -23
  62. package/Tests/Server/Infrastructure/ValkeyDeploymentConfig.test.ts +265 -0
  63. package/Tests/Server/Middleware/HttpMetricsMiddleware.test.ts +382 -0
  64. package/Tests/Server/Middleware/SCIMAuthorization.test.ts +525 -0
  65. package/Tests/Server/Services/DatabaseServiceAggregateBy.test.ts +291 -56
  66. package/Tests/Server/Services/DatabaseServiceSortTiebreaker.test.ts +151 -0
  67. package/Tests/Server/Services/InventoryItemManualCreate.test.ts +105 -0
  68. package/Tests/Server/Services/InventoryItemPromotionGate.test.ts +178 -0
  69. package/Tests/Server/Services/MonitorTemplateServiceCustomFieldSync.test.ts +502 -0
  70. package/Tests/Server/Services/NetworkDeviceAutoImportRuleEngineService.test.ts +1133 -81
  71. package/Tests/Server/Services/NetworkDeviceDiscoveryScanRegistration.test.ts +520 -0
  72. package/Tests/Server/Services/NetworkDeviceMacAddressNormalization.test.ts +572 -0
  73. package/Tests/Server/Types/Database/InventoryStatusPostgres.test.ts +368 -0
  74. package/Tests/Server/Types/Database/Permissions/ReadBlockPermission.test.ts +261 -14
  75. package/Tests/Server/Types/Database/QueryUtilIncludesAnyOfGroups.test.ts +169 -0
  76. package/Tests/Server/Types/Database/QueryUtilIncludesAnyOfGroupsPostgres.test.ts +297 -0
  77. package/Tests/Server/Types/Markdown.test.ts +194 -1
  78. package/Tests/Server/Types/Workflow/Components/IncomingWebhookUtils.test.ts +279 -0
  79. package/Tests/Server/Types/Workflow/Components/TextToJson.test.ts +212 -0
  80. package/Tests/Server/Utils/CustomField/CustomFieldMappingRegistry.test.ts +436 -0
  81. package/Tests/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.test.ts +214 -0
  82. package/Tests/Server/Utils/EmailRollup/EmailRollupFlushRunnerBehaviour.test.ts +1 -1
  83. package/Tests/Server/Utils/EmailRollup/EmailRollupRenderer.test.ts +10 -3
  84. package/Tests/Server/Utils/EventLoop.test.ts +359 -0
  85. package/Tests/Server/Utils/JsonWebToken.test.ts +30 -0
  86. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +242 -0
  87. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteriaAggregation.test.ts +13 -7
  88. package/Tests/Server/Utils/Monitor/Criteria/DatabaseMonitorCriteria.test.ts +29 -0
  89. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.test.ts +30 -6
  90. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaAnomalyUnits.test.ts +246 -0
  91. package/Tests/Server/Utils/Monitor/MonitorCriteriaEmailEvalLogAgreement.test.ts +280 -0
  92. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluator.test.ts +224 -10
  93. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorMetricUnits.test.ts +570 -0
  94. package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilderUnits.test.ts +30 -5
  95. package/Tests/Server/Utils/Monitor/MonitorCriteriaMessageBuilderUnits.test.ts +68 -21
  96. package/Tests/Server/Utils/Monitor/MonitorCriteriaObservationBuilderUnits.test.ts +36 -19
  97. package/Tests/Server/Utils/Monitor/NetworkDeviceMacLearningUtil.test.ts +573 -0
  98. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +317 -0
  99. package/Tests/Server/Utils/Telemetry/ContextSpanProcessor.test.ts +281 -0
  100. package/Tests/Server/Utils/Telemetry/InventoryDuplicateIdentity.test.ts +316 -0
  101. package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +111 -8
  102. package/Tests/Types/Database/IncludesAnyOfGroups.test.ts +106 -0
  103. package/Tests/Types/JSONFunctions.test.ts +40 -0
  104. package/Tests/Types/NetworkAutomation/AutoImportRunMerge.test.ts +235 -0
  105. package/Tests/Types/NetworkAutomation/RuleRunResult.test.ts +66 -0
  106. package/Tests/Types/NetworkAutomation/RuleRunResultDescribe.test.ts +309 -1
  107. package/Tests/Types/Telemetry/InventoryLiveness.test.ts +99 -0
  108. package/Tests/UI/Components/CustomFields/CustomFieldsDetail.test.tsx +160 -0
  109. package/Tests/UI/ReactRouterSingletonBuild.test.ts +133 -0
  110. package/Tests/UI/Rum/ReplayHeader.test.tsx +10 -2
  111. package/Tests/UI/Rum/ReplayPlayerChrome.test.tsx +514 -0
  112. package/Tests/UI/Rum/ReplayTimeFormat.test.ts +94 -0
  113. package/Tests/UI/Rum/ReplayUi.test.tsx +559 -0
  114. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +5 -0
  115. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +1 -0
  116. package/Tests/Utils/Dashboard/Components/DashboardMonitorListComponent.test.ts +11 -1
  117. package/Tests/Utils/Dashboard/LabelVariable.test.ts +257 -0
  118. package/Tests/Utils/Dashboard/VariableUrlState.test.ts +144 -14
  119. package/Tests/Utils/MetricUnitUtil.test.ts +81 -0
  120. package/Tests/Utils/Monitor/DeviceMacLearningUtil.test.ts +497 -0
  121. package/Tests/Utils/Monitor/MetricValueFormatter.test.ts +546 -0
  122. package/Tests/Utils/Monitor/MonitorTemplateCustomFieldUtil.test.ts +218 -0
  123. package/Tests/Utils/Monitor/NetworkTopologyEndpointAdoption.test.ts +1755 -0
  124. package/Tests/Utils/NetworkAutomation/AutoImportRunChain.test.ts +320 -0
  125. package/Types/BaseDatabase/IncludesAnyOfGroups.ts +77 -0
  126. package/Types/Dashboard/DashboardComponents/ComponentArgument.ts +1 -0
  127. package/Types/Dashboard/DashboardComponents/DashboardMonitorListComponent.ts +1 -0
  128. package/Types/Dashboard/DashboardVariable.ts +8 -0
  129. package/Types/JSON.ts +4 -0
  130. package/Types/JSONFunctions.ts +16 -0
  131. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +13 -0
  132. package/Types/NetworkAutomation/RuleRunResult.ts +402 -13
  133. package/Types/SerializableObjectDictionary.ts +4 -0
  134. package/Types/Telemetry/InventoryLiveness.ts +56 -0
  135. package/UI/Components/CSVFileUpload/CSVFileUpload.tsx +9 -2
  136. package/UI/Components/CustomFields/CustomFieldsDetail.tsx +46 -21
  137. package/UI/Components/Dashboard/DashboardVariableControl.tsx +285 -0
  138. package/UI/esbuild-config.js +6 -0
  139. package/Utils/Dashboard/Components/DashboardMonitorListComponent.ts +12 -1
  140. package/Utils/Dashboard/LabelVariable.ts +152 -0
  141. package/Utils/Dashboard/VariableUrlState.ts +27 -5
  142. package/Utils/MetricUnitUtil.ts +34 -0
  143. package/Utils/Monitor/DeviceMacLearningUtil.ts +177 -0
  144. package/Utils/Monitor/MetricValueFormatter.ts +342 -0
  145. package/Utils/Monitor/MonitorTemplateCustomFieldUtil.ts +124 -0
  146. package/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.ts +7 -2
  147. package/Utils/Monitor/NetworkTopologyUtil.ts +693 -0
  148. package/Utils/NetworkAutomation/AutoImportRunChain.ts +138 -0
  149. package/build/dist/Models/DatabaseModels/GlobalConfig.js +12 -12
  150. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  151. package/build/dist/Models/DatabaseModels/InventoryItem.js +34 -1
  152. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  153. package/build/dist/Models/DatabaseModels/NetworkDevice.js +128 -0
  154. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  155. package/build/dist/Server/EnvironmentConfig.js +44 -12
  156. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  157. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1791700000000-AddMacAddressToNetworkDevice.js +14 -0
  158. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1791700000000-AddMacAddressToNetworkDevice.js.map +1 -0
  159. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  160. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  161. package/build/dist/Server/Infrastructure/Redis.js +8 -8
  162. package/build/dist/Server/Infrastructure/Redis.js.map +1 -1
  163. package/build/dist/Server/Infrastructure/Status.js +10 -4
  164. package/build/dist/Server/Infrastructure/Status.js.map +1 -1
  165. package/build/dist/Server/Services/DatabaseService.js +58 -13
  166. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  167. package/build/dist/Server/Services/InventoryItemService.js +28 -1
  168. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  169. package/build/dist/Server/Services/MonitorTemplateService.js +116 -17
  170. package/build/dist/Server/Services/MonitorTemplateService.js.map +1 -1
  171. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js +270 -37
  172. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js.map +1 -1
  173. package/build/dist/Server/Services/NetworkDeviceDiscoveryScanService.js +65 -0
  174. package/build/dist/Server/Services/NetworkDeviceDiscoveryScanService.js.map +1 -1
  175. package/build/dist/Server/Services/NetworkDeviceService.js +54 -0
  176. package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
  177. package/build/dist/Server/Services/UserNotificationRuleService.js +60 -25
  178. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  179. package/build/dist/Server/Types/Database/Permissions/ReadPermission.js +29 -4
  180. package/build/dist/Server/Types/Database/Permissions/ReadPermission.js.map +1 -1
  181. package/build/dist/Server/Types/Database/QueryUtil.js +36 -0
  182. package/build/dist/Server/Types/Database/QueryUtil.js.map +1 -1
  183. package/build/dist/Server/Types/Markdown.js +75 -0
  184. package/build/dist/Server/Types/Markdown.js.map +1 -1
  185. package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js +53 -18
  186. package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js.map +1 -1
  187. package/build/dist/Server/Utils/EmailRollup/EmailRollupFlushRunner.js +10 -1
  188. package/build/dist/Server/Utils/EmailRollup/EmailRollupFlushRunner.js.map +1 -1
  189. package/build/dist/Server/Utils/EmailRollup/EmailRollupRenderer.js +2 -2
  190. package/build/dist/Server/Utils/EmailRollup/EmailRollupRenderer.js.map +1 -1
  191. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +58 -35
  192. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  193. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +29 -5
  194. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  195. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +166 -35
  196. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  197. package/build/dist/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.js +58 -9
  198. package/build/dist/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.js.map +1 -1
  199. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageBuilder.js +9 -3
  200. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageBuilder.js.map +1 -1
  201. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageFormatter.js +26 -12
  202. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageFormatter.js.map +1 -1
  203. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js +74 -33
  204. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js.map +1 -1
  205. package/build/dist/Server/Utils/Monitor/NetworkDeviceMacLearningUtil.js +114 -0
  206. package/build/dist/Server/Utils/Monitor/NetworkDeviceMacLearningUtil.js.map +1 -0
  207. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +25 -0
  208. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
  209. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +9 -1
  210. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
  211. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +70 -13
  212. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
  213. package/build/dist/Types/BaseDatabase/IncludesAnyOfGroups.js +54 -0
  214. package/build/dist/Types/BaseDatabase/IncludesAnyOfGroups.js.map +1 -0
  215. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js +1 -0
  216. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js.map +1 -1
  217. package/build/dist/Types/Dashboard/DashboardVariable.js +1 -0
  218. package/build/dist/Types/Dashboard/DashboardVariable.js.map +1 -1
  219. package/build/dist/Types/JSON.js +1 -0
  220. package/build/dist/Types/JSON.js.map +1 -1
  221. package/build/dist/Types/JSONFunctions.js +13 -0
  222. package/build/dist/Types/JSONFunctions.js.map +1 -1
  223. package/build/dist/Types/NetworkAutomation/RuleRunResult.js +291 -6
  224. package/build/dist/Types/NetworkAutomation/RuleRunResult.js.map +1 -1
  225. package/build/dist/Types/SerializableObjectDictionary.js +4 -0
  226. package/build/dist/Types/SerializableObjectDictionary.js.map +1 -1
  227. package/build/dist/Types/Telemetry/InventoryLiveness.js +37 -0
  228. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -0
  229. package/build/dist/UI/Components/CSVFileUpload/CSVFileUpload.js +7 -0
  230. package/build/dist/UI/Components/CSVFileUpload/CSVFileUpload.js.map +1 -1
  231. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +9 -2
  232. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
  233. package/build/dist/UI/Components/Dashboard/DashboardVariableControl.js +137 -0
  234. package/build/dist/UI/Components/Dashboard/DashboardVariableControl.js.map +1 -0
  235. package/build/dist/Utils/Dashboard/Components/DashboardMonitorListComponent.js +9 -1
  236. package/build/dist/Utils/Dashboard/Components/DashboardMonitorListComponent.js.map +1 -1
  237. package/build/dist/Utils/Dashboard/LabelVariable.js +96 -0
  238. package/build/dist/Utils/Dashboard/LabelVariable.js.map +1 -0
  239. package/build/dist/Utils/Dashboard/VariableUrlState.js +19 -4
  240. package/build/dist/Utils/Dashboard/VariableUrlState.js.map +1 -1
  241. package/build/dist/Utils/MetricUnitUtil.js +26 -0
  242. package/build/dist/Utils/MetricUnitUtil.js.map +1 -1
  243. package/build/dist/Utils/Monitor/DeviceMacLearningUtil.js +112 -0
  244. package/build/dist/Utils/Monitor/DeviceMacLearningUtil.js.map +1 -0
  245. package/build/dist/Utils/Monitor/MetricValueFormatter.js +276 -0
  246. package/build/dist/Utils/Monitor/MetricValueFormatter.js.map +1 -0
  247. package/build/dist/Utils/Monitor/MonitorTemplateCustomFieldUtil.js +101 -0
  248. package/build/dist/Utils/Monitor/MonitorTemplateCustomFieldUtil.js.map +1 -0
  249. package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js +7 -2
  250. package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js.map +1 -1
  251. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +484 -0
  252. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  253. package/build/dist/Utils/NetworkAutomation/AutoImportRunChain.js +59 -0
  254. package/build/dist/Utils/NetworkAutomation/AutoImportRunChain.js.map +1 -0
  255. package/jest.config.json +2 -0
  256. package/package.json +1 -1
  257. package/test-setup.sh +9 -9
  258. package/tsconfig.json +51 -11
  259. package/Tests/App/Dashboard/NotificationEmailPreferences.test.tsx +0 -399
@@ -341,6 +341,69 @@ describe("PersonalCalendarFeedCard below the plan", () => {
341
341
  ).toHaveAttribute("href", billingRoute.toString());
342
342
  });
343
343
 
344
+ /*
345
+ * The gate takes the button's PLACE - it is not an extra notice tacked on
346
+ * somewhere else. Rendering it in the empty panel's action slot is what
347
+ * makes the reader's eye land on the reason exactly where they went looking
348
+ * for the button, and it keeps the panel's shape identical on both plans.
349
+ */
350
+ test("the upgrade notice sits in the empty panel's action slot", async () => {
351
+ getMock.mockResolvedValue(ok(EMPTY_STATUS_JSON));
352
+
353
+ render(
354
+ <PersonalCalendarFeedCard
355
+ variant={PersonalCalendarFeedVariant.Full}
356
+ now={NOW}
357
+ />,
358
+ );
359
+
360
+ await waitFor(() => {
361
+ expect(
362
+ screen.getByTestId("personal-calendar-feed-empty-state"),
363
+ ).toBeInTheDocument();
364
+ });
365
+
366
+ const control: HTMLElement = screen.getByTestId(
367
+ "personal-calendar-feed-empty-control",
368
+ );
369
+
370
+ expect(control).toContainElement(
371
+ screen.getByTestId("personal-calendar-feed-plan-gate"),
372
+ );
373
+ // And the panel still explains the feature that the plan is withholding.
374
+ expect(
375
+ screen.getByTestId("personal-calendar-feed-empty-points"),
376
+ ).toBeInTheDocument();
377
+ });
378
+
379
+ test("the same holds for the shared link's Publish button", async () => {
380
+ getMock.mockResolvedValue(ok(EMPTY_STATUS_JSON));
381
+
382
+ render(
383
+ <SharedCalendarFeedCard
384
+ kind={SharedCalendarFeedKind.Schedule}
385
+ scheduleId={SCHEDULE_ID}
386
+ scheduleTimezone="Europe/Stockholm"
387
+ now={NOW}
388
+ />,
389
+ );
390
+
391
+ await waitFor(() => {
392
+ expect(
393
+ screen.getByTestId("schedule-shared-calendar-feed-empty-state"),
394
+ ).toBeInTheDocument();
395
+ });
396
+
397
+ expect(
398
+ screen.getByTestId("schedule-shared-calendar-feed-empty-control"),
399
+ ).toContainElement(
400
+ screen.getByTestId("schedule-shared-calendar-feed-plan-gate"),
401
+ );
402
+ expect(
403
+ screen.queryByTestId("schedule-shared-calendar-feed-publish"),
404
+ ).not.toBeInTheDocument();
405
+ });
406
+
344
407
  test("an existing (downgraded) feed explains itself and hides the settings card", async () => {
345
408
  getMock.mockResolvedValue(ok(ACTIVE_STATUS_JSON));
346
409
 
@@ -0,0 +1,626 @@
1
+ import "@testing-library/jest-dom";
2
+ import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ jest,
8
+ test,
9
+ } from "@jest/globals";
10
+ import {
11
+ act,
12
+ cleanup,
13
+ fireEvent,
14
+ render,
15
+ screen,
16
+ within,
17
+ } from "@testing-library/react";
18
+ import * as React from "react";
19
+ import { MemoryRouter } from "react-router-dom";
20
+ import InventoryItem from "../../../Models/DatabaseModels/InventoryItem";
21
+ import InventoryItemRelationship from "../../../Models/DatabaseModels/InventoryItemRelationship";
22
+ import EntityType from "../../../Types/Telemetry/EntityType";
23
+ import EntityRelationshipType from "../../../Types/Telemetry/EntityRelationshipType";
24
+ import TimeRange from "../../../Types/Time/TimeRange";
25
+ import { ServiceOperationalStatus } from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/OperationalOverlay";
26
+ import ServiceMapGraph from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/ServiceMapGraph";
27
+ import getJestMockFunction, { MockFunction } from "../../MockType";
28
+
29
+ const mockFitView: MockFunction = getJestMockFunction();
30
+ mockFitView.mockReturnValue(true);
31
+ const mockSetQuery: MockFunction = getJestMockFunction();
32
+ mockSetQuery.mockImplementation((values: Record<string, string | null>) => {
33
+ const url: URL = new URL(window.location.href);
34
+ for (const [key, value] of Object.entries(values)) {
35
+ if (value === null) {
36
+ url.searchParams.delete(key);
37
+ } else {
38
+ url.searchParams.set(key, value);
39
+ }
40
+ }
41
+ window.history.replaceState({}, "", url.toString());
42
+ });
43
+ let mockStatuses: Map<string, ServiceOperationalStatus> = new Map<
44
+ string,
45
+ ServiceOperationalStatus
46
+ >();
47
+
48
+ jest.mock("../../../UI/Utils/Translation", () => {
49
+ return {
50
+ __esModule: true,
51
+ default: () => {
52
+ return {
53
+ translateString: (value: string) => {
54
+ return value;
55
+ },
56
+ translateValue: (value: React.ReactNode) => {
57
+ return value;
58
+ },
59
+ };
60
+ },
61
+ };
62
+ });
63
+ jest.mock("../../../UI/Utils/Navigation", () => {
64
+ return {
65
+ __esModule: true,
66
+ default: {
67
+ getFirstParam: () => {
68
+ return null;
69
+ },
70
+ getQueryStringByName: (key: string) => {
71
+ return new URLSearchParams(window.location.search).get(key);
72
+ },
73
+ setQueryString: (values: Record<string, string | null>) => {
74
+ mockSetQuery(values);
75
+ },
76
+ },
77
+ };
78
+ });
79
+ jest.mock(
80
+ "../../../../App/FeatureSet/Dashboard/src/Components/Topology/OperationalOverlay",
81
+ () => {
82
+ return {
83
+ fetchServiceOperationalStatuses: async () => {
84
+ return mockStatuses;
85
+ },
86
+ };
87
+ },
88
+ );
89
+ jest.mock(
90
+ "../../../../App/FeatureSet/Dashboard/src/Components/Topology/EntityDetailPanel",
91
+ () => {
92
+ return {
93
+ __esModule: true,
94
+ default: (props: {
95
+ entity: InventoryItem;
96
+ onClose: () => void;
97
+ onFocus: (key: string) => void;
98
+ onSelectEntity?: (key: string) => void;
99
+ }) => {
100
+ return (
101
+ <div role="dialog" aria-label="Service details">
102
+ <p>{props.entity.displayName}</p>
103
+ <button onClick={props.onClose}>Close service</button>
104
+ <button
105
+ onClick={() => {
106
+ props.onFocus(props.entity.entityKey!);
107
+ }}
108
+ >
109
+ Focus this service
110
+ </button>
111
+ <button
112
+ onClick={() => {
113
+ props.onSelectEntity?.("db");
114
+ }}
115
+ >
116
+ View related service
117
+ </button>
118
+ </div>
119
+ );
120
+ },
121
+ };
122
+ },
123
+ );
124
+ jest.mock(
125
+ "../../../../App/FeatureSet/Dashboard/src/Components/Topology/EdgeDetailPanel",
126
+ () => {
127
+ return {
128
+ __esModule: true,
129
+ default: (props: { onClose: () => void }) => {
130
+ return (
131
+ <div role="dialog" aria-label="Connection details">
132
+ <button onClick={props.onClose}>Close connection</button>
133
+ </div>
134
+ );
135
+ },
136
+ };
137
+ },
138
+ );
139
+ jest.mock("reactflow", () => {
140
+ return {
141
+ __esModule: true,
142
+ MarkerType: { ArrowClosed: "arrowclosed" },
143
+ BackgroundVariant: { Dots: "dots" },
144
+ Background: () => {
145
+ return null;
146
+ },
147
+ Controls: () => {
148
+ return null;
149
+ },
150
+ Handle: () => {
151
+ return null;
152
+ },
153
+ Position: { Top: "top", Bottom: "bottom" },
154
+ default: (props: {
155
+ nodes: Array<{ id: string; data: { label: string; dimmed: boolean } }>;
156
+ edges: Array<{ id: string; label?: string; animated?: boolean }>;
157
+ onInit: (value: unknown) => void;
158
+ onNodeClick: (event: React.MouseEvent, node: unknown) => void;
159
+ onEdgeClick: (event: React.MouseEvent, edge: unknown) => void;
160
+ }) => {
161
+ React.useEffect(() => {
162
+ props.onInit({ fitView: mockFitView });
163
+ }, []);
164
+ return (
165
+ <div data-testid="rendered-graph">
166
+ {props.nodes.map(
167
+ (node: {
168
+ id: string;
169
+ data: { label: string; dimmed: boolean };
170
+ }) => {
171
+ return (
172
+ <button
173
+ key={node.id}
174
+ data-testid={`map-node-${node.id}`}
175
+ data-context={String(node.data.dimmed)}
176
+ onClick={(event: React.MouseEvent) => {
177
+ props.onNodeClick(event, node);
178
+ }}
179
+ >
180
+ {node.data.label}
181
+ </button>
182
+ );
183
+ },
184
+ )}
185
+ {props.edges.map(
186
+ (edge: { id: string; label?: string; animated?: boolean }) => {
187
+ return (
188
+ <button
189
+ key={edge.id}
190
+ data-testid={`map-edge-${edge.id}`}
191
+ data-animated={String(edge.animated)}
192
+ onClick={(event: React.MouseEvent) => {
193
+ props.onEdgeClick(event, edge);
194
+ }}
195
+ >
196
+ {edge.label || "Connection"}
197
+ </button>
198
+ );
199
+ },
200
+ )}
201
+ </div>
202
+ );
203
+ },
204
+ };
205
+ });
206
+
207
+ const SERVICES: Array<InventoryItem> = [
208
+ "web",
209
+ "api",
210
+ "db",
211
+ "archive",
212
+ "worker",
213
+ ].map((key: string) => {
214
+ return {
215
+ entityKey: key,
216
+ displayName: key,
217
+ entityType: EntityType.Service,
218
+ } as InventoryItem;
219
+ });
220
+ function edge(
221
+ from: string,
222
+ to: string,
223
+ calls: number,
224
+ errors: number,
225
+ ): InventoryItemRelationship {
226
+ return {
227
+ fromEntityKey: from,
228
+ toEntityKey: to,
229
+ relationshipType: EntityRelationshipType.DependsOn,
230
+ callCount: calls,
231
+ errorCount: errors,
232
+ avgDurationMs: 25,
233
+ } as InventoryItemRelationship;
234
+ }
235
+ const RELATIONSHIPS: Array<InventoryItemRelationship> = [
236
+ edge("web", "api", 100, 6),
237
+ edge("api", "db", 100, 0),
238
+ edge("db", "archive", 100, 0),
239
+ ];
240
+
241
+ async function renderGraph(
242
+ entities: Array<InventoryItem> = SERVICES,
243
+ relationships: Array<InventoryItemRelationship> = RELATIONSHIPS,
244
+ ): Promise<void> {
245
+ await act(async () => {
246
+ render(
247
+ <MemoryRouter>
248
+ <ServiceMapGraph
249
+ entities={entities}
250
+ relationships={relationships}
251
+ metricsWindowSeconds={60}
252
+ timeRange={{ range: TimeRange.PAST_ONE_HOUR }}
253
+ />
254
+ </MemoryRouter>,
255
+ );
256
+ });
257
+ }
258
+
259
+ beforeEach(() => {
260
+ window.history.replaceState({}, "", "/");
261
+ mockStatuses = new Map<string, ServiceOperationalStatus>();
262
+ mockSetQuery.mockClear();
263
+ mockFitView.mockClear();
264
+ });
265
+ afterEach(() => {
266
+ cleanup();
267
+ jest.useRealTimers();
268
+ });
269
+
270
+ describe("service directory interactions", () => {
271
+ const largeCatalog: Array<InventoryItem> = Array.from(
272
+ { length: 1000 },
273
+ (_value: unknown, index: number) => {
274
+ const key: string = `service-${String(index).padStart(4, "0")}`;
275
+ return {
276
+ entityKey: key,
277
+ displayName: key,
278
+ entityType: EntityType.Service,
279
+ } as InventoryItem;
280
+ },
281
+ );
282
+
283
+ test("large catalogs render 40 services per page with working navigation", async () => {
284
+ await renderGraph(largeCatalog, []);
285
+ expect(screen.getAllByTestId("service-map-list-row")).toHaveLength(40);
286
+ expect(screen.getByTestId("service-map-pagination")).toHaveTextContent(
287
+ "Showing 1–40 of 1000 services",
288
+ );
289
+ expect(
290
+ screen.getByRole("button", { name: "Previous service page" }),
291
+ ).toBeDisabled();
292
+ fireEvent.click(screen.getByRole("button", { name: "Next service page" }));
293
+ expect(screen.getByTestId("service-map-pagination")).toHaveTextContent(
294
+ "Showing 41–80 of 1000 services",
295
+ );
296
+ expect(
297
+ screen.getByRole("button", { name: "service-0040", exact: true }),
298
+ ).toBeInTheDocument();
299
+ expect(
300
+ screen.queryByRole("button", { name: "service-0000", exact: true }),
301
+ ).not.toBeInTheDocument();
302
+ fireEvent.click(
303
+ screen.getByRole("button", { name: "Previous service page" }),
304
+ );
305
+ expect(
306
+ screen.getByRole("button", { name: "service-0000", exact: true }),
307
+ ).toBeInTheDocument();
308
+ });
309
+
310
+ test("search finds services beyond the current page and resets pagination", async () => {
311
+ await renderGraph(largeCatalog, []);
312
+ fireEvent.click(screen.getByRole("button", { name: "Next service page" }));
313
+ fireEvent.change(screen.getByRole("textbox", { name: "Search services" }), {
314
+ target: { value: "0999" },
315
+ });
316
+ expect(screen.getAllByTestId("service-map-list-row")).toHaveLength(1);
317
+ expect(
318
+ screen.getByRole("button", { name: "service-0999", exact: true }),
319
+ ).toBeInTheDocument();
320
+ expect(
321
+ screen.queryByTestId("service-map-pagination"),
322
+ ).not.toBeInTheDocument();
323
+ fireEvent.click(screen.getByTestId("service-map-reset-filters"));
324
+ expect(screen.getByTestId("service-map-pagination")).toHaveTextContent(
325
+ "Showing 1–40 of 1000 services",
326
+ );
327
+ });
328
+
329
+ test("connection navigation in the drawer selects the related service", async () => {
330
+ await renderGraph();
331
+ fireEvent.click(screen.getByRole("button", { name: "api", exact: true }));
332
+ fireEvent.click(
333
+ screen.getByRole("button", { name: "View related service" }),
334
+ );
335
+ expect(
336
+ screen.getByRole("dialog", { name: "Service details" }),
337
+ ).toHaveTextContent("db");
338
+ });
339
+ test("a fresh project explains how to discover services and provides setup documentation", async () => {
340
+ await act(async () => {
341
+ render(
342
+ <MemoryRouter>
343
+ <ServiceMapGraph
344
+ entities={[]}
345
+ relationships={[]}
346
+ metricsWindowSeconds={60}
347
+ timeRange={{ range: TimeRange.PAST_ONE_HOUR }}
348
+ />
349
+ </MemoryRouter>,
350
+ );
351
+ });
352
+ expect(screen.getByText("No services discovered yet")).toBeInTheDocument();
353
+ expect(
354
+ screen.getByRole("link", { name: /View telemetry setup documentation/ }),
355
+ ).toHaveAttribute("href");
356
+ expect(screen.queryByTestId("service-map-list")).not.toBeInTheDocument();
357
+ });
358
+
359
+ test("operational incidents are included in attention and take precedence over traffic status", async () => {
360
+ mockStatuses.set("worker", {
361
+ serviceId: "worker-id",
362
+ activeIncidentCount: 2,
363
+ worstIncidentSeverityName: "Critical",
364
+ worstIncidentSeverityColor: "#dc2626",
365
+ incidents: [],
366
+ activeAlertCount: 1,
367
+ worstAlertSeverityName: "Warning",
368
+ worstAlertSeverityColor: "#f59e0b",
369
+ alerts: [],
370
+ });
371
+ await renderGraph();
372
+ fireEvent.click(screen.getByTestId("service-map-attention-filter"));
373
+ expect(screen.getAllByTestId("service-map-list-row")).toHaveLength(2);
374
+ expect(screen.getByText("2 active incidents")).toBeInTheDocument();
375
+ });
376
+
377
+ test("starts with a readable list and summary instead of a miniature graph", async () => {
378
+ await renderGraph();
379
+ expect(screen.getByTestId("service-map-view-list")).toHaveAttribute(
380
+ "aria-pressed",
381
+ "true",
382
+ );
383
+ expect(screen.getAllByTestId("service-map-list-row")).toHaveLength(5);
384
+ expect(screen.queryByTestId("service-map-canvas")).not.toBeInTheDocument();
385
+ expect(screen.getByTestId("service-map-summary")).toHaveTextContent(
386
+ "Without connections",
387
+ );
388
+ expect(screen.getAllByTestId("service-map-list-row")[0]).toHaveTextContent(
389
+ "api",
390
+ );
391
+ expect(screen.getByTestId("service-map-result-count")).toHaveTextContent(
392
+ "5 of 5 services",
393
+ );
394
+ });
395
+
396
+ test("search removes unrelated list rows and reports result count", async () => {
397
+ await renderGraph();
398
+ fireEvent.change(screen.getByRole("textbox", { name: "Search services" }), {
399
+ target: { value: "API" },
400
+ });
401
+ expect(screen.getAllByTestId("service-map-list-row")).toHaveLength(1);
402
+ expect(screen.getByTestId("service-map-list-row")).toHaveTextContent("api");
403
+ expect(screen.getByTestId("service-map-result-count")).toHaveTextContent(
404
+ "1 of 5 services",
405
+ );
406
+ });
407
+
408
+ test("search updates the shareable URL once typing pauses", async () => {
409
+ jest.useFakeTimers();
410
+ await renderGraph();
411
+ const search: HTMLElement = screen.getByRole("textbox", {
412
+ name: "Search services",
413
+ });
414
+ fireEvent.change(search, { target: { value: "a" } });
415
+ fireEvent.change(search, { target: { value: "api" } });
416
+ expect(window.location.search).toBe("");
417
+ act(() => {
418
+ jest.advanceTimersByTime(250);
419
+ });
420
+ expect(window.location.search).toContain("search=api");
421
+ expect(mockSetQuery).toHaveBeenCalledTimes(1);
422
+ });
423
+
424
+ test("a search miss gives a clear recovery action", async () => {
425
+ await renderGraph();
426
+ fireEvent.change(screen.getByRole("textbox", { name: "Search services" }), {
427
+ target: { value: "missing" },
428
+ });
429
+ expect(
430
+ screen.getByText("No services match your filters"),
431
+ ).toBeInTheDocument();
432
+ fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
433
+ expect(screen.getAllByTestId("service-map-list-row")).toHaveLength(5);
434
+ expect(
435
+ screen.getByRole("textbox", { name: "Search services" }),
436
+ ).toHaveValue("");
437
+ });
438
+
439
+ test("attention filtering leaves only affected services in the list", async () => {
440
+ await renderGraph();
441
+ fireEvent.click(screen.getByTestId("service-map-attention-filter"));
442
+ expect(screen.getByTestId("service-map-attention-filter")).toHaveAttribute(
443
+ "aria-pressed",
444
+ "true",
445
+ );
446
+ expect(screen.getAllByTestId("service-map-list-row")).toHaveLength(1);
447
+ expect(screen.getByTestId("service-map-list-row")).toHaveTextContent(
448
+ "High error rate",
449
+ );
450
+ fireEvent.click(screen.getByTestId("service-map-reset-filters"));
451
+ expect(screen.getAllByTestId("service-map-list-row")).toHaveLength(5);
452
+ });
453
+
454
+ test("no incoming traffic is distinguished from healthy traffic", async () => {
455
+ await renderGraph();
456
+ const workerRow: HTMLElement = screen
457
+ .getByRole("button", { name: "worker", exact: true })
458
+ .closest("tr")!;
459
+ expect(workerRow).toHaveTextContent("No incoming calls");
460
+ expect(workerRow).not.toHaveTextContent("No call errors");
461
+ expect(workerRow).toHaveTextContent("0 callers · 0 dependencies");
462
+ });
463
+
464
+ test("service names open details and drawer focus opens its dependency map", async () => {
465
+ await renderGraph();
466
+ fireEvent.click(screen.getByRole("button", { name: "api", exact: true }));
467
+ expect(
468
+ screen.getByRole("dialog", { name: "Service details" }),
469
+ ).toHaveTextContent("api");
470
+ fireEvent.click(screen.getByRole("button", { name: "Focus this service" }));
471
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
472
+ expect(screen.getByTestId("service-map-view-map")).toHaveAttribute(
473
+ "aria-pressed",
474
+ "true",
475
+ );
476
+ expect(
477
+ screen.getByText("Direct connections of", { exact: false }),
478
+ ).toHaveTextContent("api");
479
+ expect(screen.getByTestId("map-node-web")).toBeInTheDocument();
480
+ expect(screen.queryByTestId("map-node-archive")).not.toBeInTheDocument();
481
+ });
482
+ });
483
+
484
+ describe("dependency map interactions", () => {
485
+ test("view connections reveals only direct callers and dependencies", async () => {
486
+ await renderGraph();
487
+ fireEvent.click(
488
+ screen.getByRole("button", { name: "View connections for api" }),
489
+ );
490
+ expect(screen.getByTestId("map-node-api")).toBeInTheDocument();
491
+ expect(screen.getByTestId("map-node-web")).toBeInTheDocument();
492
+ expect(screen.getByTestId("map-node-db")).toBeInTheDocument();
493
+ expect(screen.queryByTestId("map-node-archive")).not.toBeInTheDocument();
494
+ expect(window.location.search).toContain("serviceView=map");
495
+ expect(window.location.search).toContain("focus=api");
496
+ fireEvent.click(screen.getByTestId("service-map-clear-focus"));
497
+ expect(screen.getByTestId("map-node-archive")).toBeInTheDocument();
498
+ expect(screen.getByTestId("map-node-worker")).toBeInTheDocument();
499
+ });
500
+
501
+ test("saved map URLs restore map selection, search and focus", async () => {
502
+ window.history.replaceState(
503
+ {},
504
+ "",
505
+ "/?serviceView=map&focus=api&search=api",
506
+ );
507
+ await renderGraph();
508
+ expect(screen.getByTestId("service-map-view-map")).toHaveAttribute(
509
+ "aria-pressed",
510
+ "true",
511
+ );
512
+ expect(
513
+ screen.getByRole("textbox", { name: "Search services" }),
514
+ ).toHaveValue("api");
515
+ expect(screen.getByTestId("map-node-web")).toHaveAttribute(
516
+ "data-context",
517
+ "true",
518
+ );
519
+ expect(screen.getByTestId("map-node-api")).toHaveAttribute(
520
+ "data-context",
521
+ "false",
522
+ );
523
+ expect(screen.queryByTestId("map-node-archive")).not.toBeInTheDocument();
524
+ expect(screen.getByTestId("service-map-result-count")).toHaveTextContent(
525
+ "2 connected services for context",
526
+ );
527
+ });
528
+
529
+ test("map metrics stay quiet until selected, with no animated edges", async () => {
530
+ window.history.replaceState({}, "", "/?serviceView=map");
531
+ await renderGraph();
532
+ const connection: HTMLElement = screen.getByTestId("map-edge-web->api");
533
+ expect(connection).toHaveTextContent("Connection");
534
+ expect(connection).toHaveAttribute("data-animated", "false");
535
+ fireEvent.change(
536
+ screen.getByRole("combobox", { name: "Connection labels" }),
537
+ { target: { value: "errors" } },
538
+ );
539
+ expect(connection).toHaveTextContent("6.0% errors");
540
+ fireEvent.change(
541
+ screen.getByRole("combobox", { name: "Connection labels" }),
542
+ { target: { value: "latency" } },
543
+ );
544
+ expect(connection).toHaveTextContent("25ms");
545
+ fireEvent.change(
546
+ screen.getByRole("combobox", { name: "Connection labels" }),
547
+ { target: { value: "calls" } },
548
+ );
549
+ expect(connection).toHaveTextContent("100/min");
550
+ });
551
+
552
+ test("service and connection drawers remain exclusive", async () => {
553
+ window.history.replaceState({}, "", "/?serviceView=map");
554
+ await renderGraph();
555
+ fireEvent.click(screen.getByTestId("map-node-api"));
556
+ expect(
557
+ screen.getByRole("dialog", { name: "Service details" }),
558
+ ).toBeInTheDocument();
559
+ fireEvent.click(screen.getByTestId("map-edge-web->api"));
560
+ expect(
561
+ screen.queryByRole("dialog", { name: "Service details" }),
562
+ ).not.toBeInTheDocument();
563
+ expect(
564
+ screen.getByRole("dialog", { name: "Connection details" }),
565
+ ).toBeInTheDocument();
566
+ fireEvent.click(screen.getByTestId("map-node-web"));
567
+ expect(
568
+ screen.queryByRole("dialog", { name: "Connection details" }),
569
+ ).not.toBeInTheDocument();
570
+ });
571
+
572
+ test("reframes new search matches even when their node count is unchanged", async () => {
573
+ window.history.replaceState({}, "", "/?serviceView=map");
574
+ await renderGraph();
575
+ fireEvent.change(screen.getByRole("textbox", { name: "Search services" }), {
576
+ target: { value: "web" },
577
+ });
578
+ mockFitView.mockClear();
579
+ fireEvent.change(screen.getByRole("textbox", { name: "Search services" }), {
580
+ target: { value: "archive" },
581
+ });
582
+ expect(mockFitView).toHaveBeenCalled();
583
+ expect(screen.getByTestId("map-node-archive")).toBeInTheDocument();
584
+ expect(screen.queryByTestId("map-node-web")).not.toBeInTheDocument();
585
+ });
586
+
587
+ test("fit-to-screen control provides an explicit way to recover the viewport", async () => {
588
+ window.history.replaceState({}, "", "/?serviceView=map");
589
+ await renderGraph();
590
+ mockFitView.mockClear();
591
+ fireEvent.click(screen.getByRole("button", { name: "Fit to screen" }));
592
+ expect(mockFitView).toHaveBeenCalledWith({
593
+ padding: 0.18,
594
+ maxZoom: 1,
595
+ duration: 300,
596
+ });
597
+ });
598
+
599
+ test("switching back to the list preserves current search", async () => {
600
+ window.history.replaceState({}, "", "/?serviceView=map&search=api");
601
+ await renderGraph();
602
+ fireEvent.click(screen.getByTestId("service-map-view-list"));
603
+ expect(screen.getAllByTestId("service-map-list-row")).toHaveLength(1);
604
+ expect(
605
+ within(screen.getByTestId("service-map-list-row")).getByRole("button", {
606
+ name: "api",
607
+ exact: true,
608
+ }),
609
+ ).toBeInTheDocument();
610
+ expect(window.location.search).not.toContain("serviceView");
611
+ });
612
+
613
+ test("unmount cancels a pending search URL update instead of leaking it to another tab", async () => {
614
+ jest.useFakeTimers();
615
+ await renderGraph();
616
+ fireEvent.change(screen.getByRole("textbox", { name: "Search services" }), {
617
+ target: { value: "api" },
618
+ });
619
+ cleanup();
620
+ mockSetQuery.mockClear();
621
+ act(() => {
622
+ jest.runOnlyPendingTimers();
623
+ });
624
+ expect(mockSetQuery).not.toHaveBeenCalled();
625
+ });
626
+ });