@miragon/pulse-analytics-connector 0.0.0-stage → 0.21.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 (357) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +87 -2
  3. package/dist/bpmn-name-lookup.d.ts +72 -0
  4. package/dist/bpmn-name-lookup.d.ts.map +1 -0
  5. package/dist/bpmn-name-lookup.js +123 -0
  6. package/dist/bpmn-name-lookup.js.map +1 -0
  7. package/dist/bpmn-name-lookup.test.d.ts +2 -0
  8. package/dist/bpmn-name-lookup.test.d.ts.map +1 -0
  9. package/dist/bpmn-name-lookup.test.js +287 -0
  10. package/dist/bpmn-name-lookup.test.js.map +1 -0
  11. package/dist/bpmn-names.d.ts +38 -0
  12. package/dist/bpmn-names.d.ts.map +1 -0
  13. package/dist/bpmn-names.js +22 -0
  14. package/dist/bpmn-names.js.map +1 -0
  15. package/dist/brand-gates.test.d.ts +2 -0
  16. package/dist/brand-gates.test.d.ts.map +1 -0
  17. package/dist/brand-gates.test.js +58 -0
  18. package/dist/brand-gates.test.js.map +1 -0
  19. package/dist/builder-texts.d.ts +4 -0
  20. package/dist/builder-texts.d.ts.map +1 -0
  21. package/dist/builder-texts.js +14 -0
  22. package/dist/builder-texts.js.map +1 -0
  23. package/dist/builder-texts.test.d.ts +2 -0
  24. package/dist/builder-texts.test.d.ts.map +1 -0
  25. package/dist/builder-texts.test.js +32 -0
  26. package/dist/builder-texts.test.js.map +1 -0
  27. package/dist/definition.d.ts +3 -0
  28. package/dist/definition.d.ts.map +1 -0
  29. package/dist/definition.js +151 -0
  30. package/dist/definition.js.map +1 -0
  31. package/dist/engine-ids.d.ts +42 -0
  32. package/dist/engine-ids.d.ts.map +1 -0
  33. package/dist/engine-ids.js +50 -0
  34. package/dist/engine-ids.js.map +1 -0
  35. package/dist/engine-scope.test.d.ts +2 -0
  36. package/dist/engine-scope.test.d.ts.map +1 -0
  37. package/dist/engine-scope.test.js +260 -0
  38. package/dist/engine-scope.test.js.map +1 -0
  39. package/dist/heatmap-data.d.ts +15 -0
  40. package/dist/heatmap-data.d.ts.map +1 -0
  41. package/dist/heatmap-data.js +2 -0
  42. package/dist/heatmap-data.js.map +1 -0
  43. package/dist/index.d.ts +5 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +3 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/instructions.d.ts +8 -0
  48. package/dist/instructions.d.ts.map +1 -0
  49. package/dist/instructions.js +33 -0
  50. package/dist/instructions.js.map +1 -0
  51. package/dist/messages/de.ask-ai.d.ts +9 -0
  52. package/dist/messages/de.ask-ai.d.ts.map +1 -0
  53. package/dist/messages/de.ask-ai.js +18 -0
  54. package/dist/messages/de.ask-ai.js.map +1 -0
  55. package/dist/messages/de.builder.d.ts +12 -0
  56. package/dist/messages/de.builder.d.ts.map +1 -0
  57. package/dist/messages/de.builder.js +53 -0
  58. package/dist/messages/de.builder.js.map +1 -0
  59. package/dist/messages/de.server.d.ts +8 -0
  60. package/dist/messages/de.server.d.ts.map +1 -0
  61. package/dist/messages/de.server.js +26 -0
  62. package/dist/messages/de.server.js.map +1 -0
  63. package/dist/messages/de.sweep.d.ts +10 -0
  64. package/dist/messages/de.sweep.d.ts.map +1 -0
  65. package/dist/messages/de.sweep.js +203 -0
  66. package/dist/messages/de.sweep.js.map +1 -0
  67. package/dist/messages/en.ask-ai.d.ts +25 -0
  68. package/dist/messages/en.ask-ai.d.ts.map +1 -0
  69. package/dist/messages/en.ask-ai.js +24 -0
  70. package/dist/messages/en.ask-ai.js.map +1 -0
  71. package/dist/messages/en.builder.d.ts +12 -0
  72. package/dist/messages/en.builder.d.ts.map +1 -0
  73. package/dist/messages/en.builder.js +53 -0
  74. package/dist/messages/en.builder.js.map +1 -0
  75. package/dist/messages/en.server.d.ts +8 -0
  76. package/dist/messages/en.server.d.ts.map +1 -0
  77. package/dist/messages/en.server.js +26 -0
  78. package/dist/messages/en.server.js.map +1 -0
  79. package/dist/messages/en.sweep.d.ts +11 -0
  80. package/dist/messages/en.sweep.d.ts.map +1 -0
  81. package/dist/messages/en.sweep.js +204 -0
  82. package/dist/messages/en.sweep.js.map +1 -0
  83. package/dist/messages/glossary.d.ts +26 -0
  84. package/dist/messages/glossary.d.ts.map +1 -0
  85. package/dist/messages/glossary.js +37 -0
  86. package/dist/messages/glossary.js.map +1 -0
  87. package/dist/messages/glossary.test.d.ts +2 -0
  88. package/dist/messages/glossary.test.d.ts.map +1 -0
  89. package/dist/messages/glossary.test.js +109 -0
  90. package/dist/messages/glossary.test.js.map +1 -0
  91. package/dist/messages/index.d.ts +12 -0
  92. package/dist/messages/index.d.ts.map +1 -0
  93. package/dist/messages/index.js +23 -0
  94. package/dist/messages/index.js.map +1 -0
  95. package/dist/messages/plural.d.ts +10 -0
  96. package/dist/messages/plural.d.ts.map +1 -0
  97. package/dist/messages/plural.js +17 -0
  98. package/dist/messages/plural.js.map +1 -0
  99. package/dist/messages/use-t.d.ts +10 -0
  100. package/dist/messages/use-t.d.ts.map +1 -0
  101. package/dist/messages/use-t.js +10 -0
  102. package/dist/messages/use-t.js.map +1 -0
  103. package/dist/module.d.ts +48 -0
  104. package/dist/module.d.ts.map +1 -0
  105. package/dist/module.js +174 -0
  106. package/dist/module.js.map +1 -0
  107. package/dist/module.test.d.ts +2 -0
  108. package/dist/module.test.d.ts.map +1 -0
  109. package/dist/module.test.js +230 -0
  110. package/dist/module.test.js.map +1 -0
  111. package/dist/named-views.d.ts +27 -0
  112. package/dist/named-views.d.ts.map +1 -0
  113. package/dist/named-views.js +73 -0
  114. package/dist/named-views.js.map +1 -0
  115. package/dist/named-views.test.d.ts +2 -0
  116. package/dist/named-views.test.d.ts.map +1 -0
  117. package/dist/named-views.test.js +110 -0
  118. package/dist/named-views.test.js.map +1 -0
  119. package/dist/plugin.d.ts +44 -0
  120. package/dist/plugin.d.ts.map +1 -0
  121. package/dist/plugin.js +52 -0
  122. package/dist/plugin.js.map +1 -0
  123. package/dist/prometheus-wiring.test.d.ts +2 -0
  124. package/dist/prometheus-wiring.test.d.ts.map +1 -0
  125. package/dist/prometheus-wiring.test.js +142 -0
  126. package/dist/prometheus-wiring.test.js.map +1 -0
  127. package/dist/server-locale.d.ts +48 -0
  128. package/dist/server-locale.d.ts.map +1 -0
  129. package/dist/server-locale.js +33 -0
  130. package/dist/server-locale.js.map +1 -0
  131. package/dist/settings-tools.d.ts +23 -0
  132. package/dist/settings-tools.d.ts.map +1 -0
  133. package/dist/settings-tools.js +100 -0
  134. package/dist/settings-tools.js.map +1 -0
  135. package/dist/settings.d.ts +77 -0
  136. package/dist/settings.d.ts.map +1 -0
  137. package/dist/settings.js +99 -0
  138. package/dist/settings.js.map +1 -0
  139. package/dist/settings.test.d.ts +2 -0
  140. package/dist/settings.test.d.ts.map +1 -0
  141. package/dist/settings.test.js +299 -0
  142. package/dist/settings.test.js.map +1 -0
  143. package/dist/steps/app-config.d.ts +30 -0
  144. package/dist/steps/app-config.d.ts.map +1 -0
  145. package/dist/steps/app-config.js +46 -0
  146. package/dist/steps/app-config.js.map +1 -0
  147. package/dist/steps/dashboard.d.ts +12 -0
  148. package/dist/steps/dashboard.d.ts.map +1 -0
  149. package/dist/steps/dashboard.js +58 -0
  150. package/dist/steps/dashboard.js.map +1 -0
  151. package/dist/steps/failure-dashboard.d.ts +10 -0
  152. package/dist/steps/failure-dashboard.d.ts.map +1 -0
  153. package/dist/steps/failure-dashboard.js +32 -0
  154. package/dist/steps/failure-dashboard.js.map +1 -0
  155. package/dist/steps/index.d.ts +3 -0
  156. package/dist/steps/index.d.ts.map +1 -0
  157. package/dist/steps/index.js +3 -0
  158. package/dist/steps/index.js.map +1 -0
  159. package/dist/steps/steps.test.d.ts +2 -0
  160. package/dist/steps/steps.test.d.ts.map +1 -0
  161. package/dist/steps/steps.test.js +153 -0
  162. package/dist/steps/steps.test.js.map +1 -0
  163. package/dist/tool-names.d.ts +13 -0
  164. package/dist/tool-names.d.ts.map +1 -0
  165. package/dist/tool-names.js +27 -0
  166. package/dist/tool-names.js.map +1 -0
  167. package/dist/tools/cluster-compare.d.ts +8 -0
  168. package/dist/tools/cluster-compare.d.ts.map +1 -0
  169. package/dist/tools/cluster-compare.js +17 -0
  170. package/dist/tools/cluster-compare.js.map +1 -0
  171. package/dist/tools/element.d.ts +8 -0
  172. package/dist/tools/element.d.ts.map +1 -0
  173. package/dist/tools/element.js +25 -0
  174. package/dist/tools/element.js.map +1 -0
  175. package/dist/tools/engine-compare.d.ts +8 -0
  176. package/dist/tools/engine-compare.d.ts.map +1 -0
  177. package/dist/tools/engine-compare.js +18 -0
  178. package/dist/tools/engine-compare.js.map +1 -0
  179. package/dist/tools/engine-compare.test.d.ts +2 -0
  180. package/dist/tools/engine-compare.test.d.ts.map +1 -0
  181. package/dist/tools/engine-compare.test.js +166 -0
  182. package/dist/tools/engine-compare.test.js.map +1 -0
  183. package/dist/tools/engine-landscape.d.ts +7 -0
  184. package/dist/tools/engine-landscape.d.ts.map +1 -0
  185. package/dist/tools/engine-landscape.js +13 -0
  186. package/dist/tools/engine-landscape.js.map +1 -0
  187. package/dist/tools/engine-landscape.test.d.ts +2 -0
  188. package/dist/tools/engine-landscape.test.d.ts.map +1 -0
  189. package/dist/tools/engine-landscape.test.js +110 -0
  190. package/dist/tools/engine-landscape.test.js.map +1 -0
  191. package/dist/tools/failures.d.ts +7 -0
  192. package/dist/tools/failures.d.ts.map +1 -0
  193. package/dist/tools/failures.js +13 -0
  194. package/dist/tools/failures.js.map +1 -0
  195. package/dist/tools/health.d.ts +7 -0
  196. package/dist/tools/health.d.ts.map +1 -0
  197. package/dist/tools/health.js +23 -0
  198. package/dist/tools/health.js.map +1 -0
  199. package/dist/tools/health.test.d.ts +2 -0
  200. package/dist/tools/health.test.d.ts.map +1 -0
  201. package/dist/tools/health.test.js +119 -0
  202. package/dist/tools/health.test.js.map +1 -0
  203. package/dist/tools/index.d.ts +18 -0
  204. package/dist/tools/index.d.ts.map +1 -0
  205. package/dist/tools/index.js +35 -0
  206. package/dist/tools/index.js.map +1 -0
  207. package/dist/tools/performance.d.ts +8 -0
  208. package/dist/tools/performance.d.ts.map +1 -0
  209. package/dist/tools/performance.js +25 -0
  210. package/dist/tools/performance.js.map +1 -0
  211. package/dist/tools/performance.test.d.ts +2 -0
  212. package/dist/tools/performance.test.d.ts.map +1 -0
  213. package/dist/tools/performance.test.js +176 -0
  214. package/dist/tools/performance.test.js.map +1 -0
  215. package/dist/tools/version-compare.d.ts +8 -0
  216. package/dist/tools/version-compare.d.ts.map +1 -0
  217. package/dist/tools/version-compare.js +17 -0
  218. package/dist/tools/version-compare.js.map +1 -0
  219. package/dist/tools/version-compare.test.d.ts +2 -0
  220. package/dist/tools/version-compare.test.d.ts.map +1 -0
  221. package/dist/tools/version-compare.test.js +82 -0
  222. package/dist/tools/version-compare.test.js.map +1 -0
  223. package/dist/toolsets.d.ts +17 -0
  224. package/dist/toolsets.d.ts.map +1 -0
  225. package/dist/toolsets.js +47 -0
  226. package/dist/toolsets.js.map +1 -0
  227. package/dist/toolsets.test.d.ts +2 -0
  228. package/dist/toolsets.test.d.ts.map +1 -0
  229. package/dist/toolsets.test.js +91 -0
  230. package/dist/toolsets.test.js.map +1 -0
  231. package/dist/view-title.test.d.ts +2 -0
  232. package/dist/view-title.test.d.ts.map +1 -0
  233. package/dist/view-title.test.js +91 -0
  234. package/dist/view-title.test.js.map +1 -0
  235. package/dist/widget-tools/comparisons.d.ts +13 -0
  236. package/dist/widget-tools/comparisons.d.ts.map +1 -0
  237. package/dist/widget-tools/comparisons.js +177 -0
  238. package/dist/widget-tools/comparisons.js.map +1 -0
  239. package/dist/widget-tools/comparisons.test.d.ts +2 -0
  240. package/dist/widget-tools/comparisons.test.d.ts.map +1 -0
  241. package/dist/widget-tools/comparisons.test.js +123 -0
  242. package/dist/widget-tools/comparisons.test.js.map +1 -0
  243. package/dist/widget-tools/shared.d.ts +36 -0
  244. package/dist/widget-tools/shared.d.ts.map +1 -0
  245. package/dist/widget-tools/shared.js +39 -0
  246. package/dist/widget-tools/shared.js.map +1 -0
  247. package/dist/widget-tools.d.ts +25 -0
  248. package/dist/widget-tools.d.ts.map +1 -0
  249. package/dist/widget-tools.js +269 -0
  250. package/dist/widget-tools.js.map +1 -0
  251. package/dist/widget-tools.test.d.ts +2 -0
  252. package/dist/widget-tools.test.d.ts.map +1 -0
  253. package/dist/widget-tools.test.js +272 -0
  254. package/dist/widget-tools.test.js.map +1 -0
  255. package/package.json +91 -3
  256. package/src/bpmn-name-lookup.test.ts +333 -0
  257. package/src/bpmn-name-lookup.ts +197 -0
  258. package/src/bpmn-names.ts +51 -0
  259. package/src/brand-gates.test.ts +69 -0
  260. package/src/builder-texts.test.ts +40 -0
  261. package/src/builder-texts.ts +15 -0
  262. package/src/definition.ts +181 -0
  263. package/src/engine-ids.ts +85 -0
  264. package/src/engine-scope.test.ts +324 -0
  265. package/src/heatmap-data.ts +14 -0
  266. package/src/index.ts +4 -0
  267. package/src/instructions.ts +33 -0
  268. package/src/messages/GLOSSARY.md +88 -0
  269. package/src/messages/de.ask-ai.ts +28 -0
  270. package/src/messages/de.builder.ts +77 -0
  271. package/src/messages/de.server.ts +39 -0
  272. package/src/messages/de.sweep.ts +244 -0
  273. package/src/messages/en.ask-ai.ts +34 -0
  274. package/src/messages/en.builder.ts +76 -0
  275. package/src/messages/en.server.ts +39 -0
  276. package/src/messages/en.sweep.ts +240 -0
  277. package/src/messages/glossary.test.ts +122 -0
  278. package/src/messages/glossary.ts +62 -0
  279. package/src/messages/index.ts +24 -0
  280. package/src/messages/plural.ts +18 -0
  281. package/src/messages/use-t.ts +12 -0
  282. package/src/module.test.ts +319 -0
  283. package/src/module.ts +211 -0
  284. package/src/named-views.test.ts +141 -0
  285. package/src/named-views.ts +97 -0
  286. package/src/plugin.ts +94 -0
  287. package/src/prometheus-wiring.test.ts +174 -0
  288. package/src/server-locale.ts +59 -0
  289. package/src/settings-tools.ts +170 -0
  290. package/src/settings.test.ts +402 -0
  291. package/src/settings.ts +114 -0
  292. package/src/steps/app-config.ts +75 -0
  293. package/src/steps/dashboard.ts +73 -0
  294. package/src/steps/failure-dashboard.ts +38 -0
  295. package/src/steps/index.ts +2 -0
  296. package/src/steps/steps.test.ts +216 -0
  297. package/src/tool-names.ts +26 -0
  298. package/src/tools/cluster-compare.ts +28 -0
  299. package/src/tools/element.ts +35 -0
  300. package/src/tools/engine-compare.test.ts +210 -0
  301. package/src/tools/engine-compare.ts +30 -0
  302. package/src/tools/engine-landscape.test.ts +138 -0
  303. package/src/tools/engine-landscape.ts +21 -0
  304. package/src/tools/failures.ts +19 -0
  305. package/src/tools/health.test.ts +156 -0
  306. package/src/tools/health.ts +29 -0
  307. package/src/tools/index.ts +44 -0
  308. package/src/tools/performance.test.ts +210 -0
  309. package/src/tools/performance.ts +38 -0
  310. package/src/tools/version-compare.test.ts +97 -0
  311. package/src/tools/version-compare.ts +28 -0
  312. package/src/toolsets.test.ts +107 -0
  313. package/src/toolsets.ts +56 -0
  314. package/src/view-title.test.ts +123 -0
  315. package/src/widget-tools/comparisons.test.ts +170 -0
  316. package/src/widget-tools/comparisons.ts +229 -0
  317. package/src/widget-tools/shared.ts +67 -0
  318. package/src/widget-tools.test.ts +306 -0
  319. package/src/widget-tools.ts +375 -0
  320. package/src/widgets/analytics-dashboard/activity-bottleneck-table.tsx +179 -0
  321. package/src/widgets/analytics-dashboard/definition-breakdown.tsx +108 -0
  322. package/src/widgets/analytics-dashboard/execution-performance-kpi.tsx +94 -0
  323. package/src/widgets/analytics-dashboard/execution-summary-kpi.tsx +122 -0
  324. package/src/widgets/analytics-dashboard/lib.ts +43 -0
  325. package/src/widgets/bpmn-heatmap.test.tsx +75 -0
  326. package/src/widgets/bpmn-heatmap.tsx +127 -0
  327. package/src/widgets/catalogue-sync.test.ts +16 -0
  328. package/src/widgets/cluster-compare.tsx +148 -0
  329. package/src/widgets/comparison-shared.test.ts +237 -0
  330. package/src/widgets/comparison-shared.tsx +371 -0
  331. package/src/widgets/comparison-widgets.test.tsx +307 -0
  332. package/src/widgets/dashboard-widgets.test.tsx +397 -0
  333. package/src/widgets/engine-compare.tsx +87 -0
  334. package/src/widgets/engine-landscape.tsx +352 -0
  335. package/src/widgets/failure-dashboard/error-patterns-table.tsx +122 -0
  336. package/src/widgets/failure-dashboard/failure-rate-table.test.ts +80 -0
  337. package/src/widgets/failure-dashboard/failure-rate-table.tsx +170 -0
  338. package/src/widgets/failure-dashboard/lib.ts +32 -0
  339. package/src/widgets/failure-dashboard/summary-kpi.tsx +122 -0
  340. package/src/widgets/failure-landscape-widgets.test.tsx +393 -0
  341. package/src/widgets/figure.tsx +79 -0
  342. package/src/widgets/hand-off.test.ts +126 -0
  343. package/src/widgets/hand-off.ts +102 -0
  344. package/src/widgets/index.ts +124 -0
  345. package/src/widgets/model-descriptions.dashboard.test.ts +280 -0
  346. package/src/widgets/model-descriptions.test.ts +251 -0
  347. package/src/widgets/model-descriptions.ts +428 -0
  348. package/src/widgets/names.test.ts +32 -0
  349. package/src/widgets/names.ts +39 -0
  350. package/src/widgets/query-gate.test.tsx +57 -0
  351. package/src/widgets/query-gate.tsx +50 -0
  352. package/src/widgets/settings-section.test.tsx +174 -0
  353. package/src/widgets/settings-section.tsx +244 -0
  354. package/src/widgets/version-compare.test.ts +68 -0
  355. package/src/widgets/version-compare.tsx +96 -0
  356. package/src/widgets/view-meta.ts +18 -0
  357. package/src/widgets/widget-render.test-support.tsx +103 -0
@@ -0,0 +1,97 @@
1
+ import { beforeEach, describe, expect, it } from "vitest"
2
+ import type { PrometheusClient, VersionCompareResult } from "@miragon/pulse-analytics-client"
3
+ import type { RegisteredToolMeta, ToolConfig } from "@miragon/mcp-toolkit-core/tools"
4
+ import { createEngineScope } from "../engine-ids.js"
5
+ import { registerVersionCompareTools } from "./version-compare.js"
6
+
7
+ type Config = ToolConfig<PrometheusClient>
8
+
9
+ /** Registrar stand-in that captures the tool's full config instead of an MCPServer. */
10
+ function captureConfig(): Config {
11
+ const configs: Config[] = []
12
+ const register = Object.assign((config: Config) => configs.push(config), {
13
+ getRegisteredTools: (): RegisteredToolMeta[] => [],
14
+ })
15
+ registerVersionCompareTools(register as never, createEngineScope(["prod-a"]))
16
+ expect(configs.map((c) => c.name)).toEqual(["analytics_version_compare"])
17
+ return configs[0]
18
+ }
19
+
20
+ /** PrometheusClient that records every instant PromQL string and answers 20 per query. */
21
+ function recordingClient(): { client: PrometheusClient; queries: string[] } {
22
+ const queries: string[] = []
23
+ return {
24
+ client: {
25
+ instant: (query: string) => {
26
+ queries.push(query)
27
+ return Promise.resolve([{ metric: {}, value: 20 }])
28
+ },
29
+ },
30
+ queries,
31
+ }
32
+ }
33
+
34
+ const args = { processDefinitionKey: "order", versionA: 1, versionB: 2, windowDays: 14 }
35
+
36
+ describe("analytics_version_compare", () => {
37
+ // Captured per test (not at collection time), so a registration that
38
+ // registers nothing fails a test instead of the file's collection.
39
+ let config: Config
40
+ beforeEach(() => {
41
+ config = captureConfig()
42
+ })
43
+
44
+ it("reads incidents per version, on the version label (#337)", async () => {
45
+ const { client, queries } = recordingClient()
46
+ const res = (await config.handler(client, { ...args })) as VersionCompareResult
47
+
48
+ const incidentQueries = queries.filter((q) => q.includes("camunda_incident_created_total"))
49
+ expect(incidentQueries).toHaveLength(2)
50
+ expect(incidentQueries.map((q) => /process_definition_version="(\d+)"/.exec(q)?.[1])).toEqual([
51
+ "1",
52
+ "2",
53
+ ])
54
+ for (const kpi of res.kpis) {
55
+ expect(kpi).toMatchObject({
56
+ instance_count: 20,
57
+ incident_count: 20,
58
+ incident_rate_pct: 100,
59
+ element_incident_count: null,
60
+ element_incident_rate_pct: null,
61
+ })
62
+ }
63
+ expect(res.delta.incident_rate_delta_pp).toBe(0)
64
+ })
65
+
66
+ it("tells the model the incident figures are per version and the comparison process-wide", () => {
67
+ expect(config.description).toContain("process-instance and incident series")
68
+ expect(config.description).toContain("element_incident_* fields stay null")
69
+ })
70
+
71
+ it("accepts the version-compare input plus the optional minBucketSize override", () => {
72
+ expect(Object.keys(config.inputSchema ?? {})).toEqual(
73
+ expect.arrayContaining(["processDefinitionKey", "versionA", "versionB", "minBucketSize"]),
74
+ )
75
+ })
76
+
77
+ it("registers as a read-only external read under the analytics category", () => {
78
+ expect(config.category).toBe("analytics")
79
+ expect(config.annotations).toEqual({
80
+ readOnlyHint: true,
81
+ idempotentHint: true,
82
+ openWorldHint: true,
83
+ })
84
+ })
85
+
86
+ it("prefers an explicit minBucketSize over the resolved setting", async () => {
87
+ const { client } = recordingClient()
88
+ const explicit = (await config.handler(client, {
89
+ ...args,
90
+ minBucketSize: 3,
91
+ })) as VersionCompareResult
92
+ const fallback = (await config.handler(client, { ...args })) as VersionCompareResult
93
+ expect(explicit.minBucketSize).toBe(3)
94
+ // Without a store the registrar path lands on the schema default.
95
+ expect(fallback.minBucketSize).toBe(10)
96
+ })
97
+ })
@@ -0,0 +1,28 @@
1
+ import type { PrometheusClient } from "@miragon/pulse-analytics-client"
2
+ import { schemas, queries } from "@miragon/pulse-analytics-client"
3
+ import type { createToolRegistrar } from "@miragon/mcp-toolkit-core/tools"
4
+ import type { ProfileSource } from "../server-locale.js"
5
+ import { optionalMinBucketSize, settingsFor } from "../settings.js"
6
+ import { withEngineScope, type AnalyticsEngineScope } from "../engine-ids.js"
7
+
8
+ type Register = ReturnType<typeof createToolRegistrar<PrometheusClient>>
9
+
10
+ export function registerVersionCompareTools(
11
+ register: Register,
12
+ engineScope: AnalyticsEngineScope,
13
+ profileStore?: ProfileSource,
14
+ ) {
15
+ register({
16
+ name: "analytics_version_compare",
17
+ category: "analytics",
18
+ description:
19
+ "Compare KPIs between two deployed process definition versions of the same processDefinitionKey over a shared rolling window, from metrics. The version is an exact label on the process-instance and incident series, so instance counts, completed counts, incidents, the incident rate and durations (avg, p95) per version plus their deltas are precise. Process-wide: the element_incident_* fields stay null. Flagged `suppressed` when either version has fewer than minBucketSize started or completed instances. Pair with camunda7_list_process_definitions to discover versions.",
20
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
21
+ inputSchema: { ...schemas.versionCompareInput.shape, minBucketSize: optionalMinBucketSize },
22
+ handler: async (ch, args, ctx) =>
23
+ queries.versionCompare(ch, {
24
+ ...withEngineScope(engineScope, args),
25
+ minBucketSize: args.minBucketSize ?? (await settingsFor(profileStore, ctx)).minBucketSize,
26
+ }),
27
+ })
28
+ }
@@ -0,0 +1,107 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest"
2
+ import {
3
+ allowsDurableWrites,
4
+ ANALYTICS_TOOLSETS,
5
+ analyticsToolsets,
6
+ isAnalyticsToolset,
7
+ } from "./toolsets.js"
8
+
9
+ afterEach(() => {
10
+ vi.restoreAllMocks()
11
+ })
12
+
13
+ describe("analyticsToolsets (the vocabulary the module declares)", () => {
14
+ it("declares exactly read-only and standard — analytics has no admin tier", () => {
15
+ expect(ANALYTICS_TOOLSETS).toEqual(["read-only", "standard"])
16
+ expect(analyticsToolsets.module).toBe("analytics")
17
+ expect(analyticsToolsets.names).toEqual(["read-only", "standard"])
18
+ })
19
+
20
+ it("floors at read-only and defaults to standard under OAuth", () => {
21
+ expect(analyticsToolsets.fallback).toBe("read-only")
22
+ expect(analyticsToolsets.authenticatedDefault).toBe("standard")
23
+ })
24
+
25
+ it("no suffix: read-only without OAuth, standard with it — never 'everything'", () => {
26
+ expect(analyticsToolsets.effective(undefined, { authenticated: false })).toEqual({
27
+ toolset: "read-only",
28
+ source: "default",
29
+ })
30
+ expect(analyticsToolsets.effective(undefined, { authenticated: true })).toEqual({
31
+ toolset: "standard",
32
+ source: "default",
33
+ })
34
+ })
35
+
36
+ it("a named suffix wins over the auth-dependent default, in both directions", () => {
37
+ expect(analyticsToolsets.effective("standard", { authenticated: false })).toEqual({
38
+ toolset: "standard",
39
+ source: "suffix",
40
+ })
41
+ expect(analyticsToolsets.effective("read-only", { authenticated: true })).toEqual({
42
+ toolset: "read-only",
43
+ source: "suffix",
44
+ })
45
+ })
46
+
47
+ it("an empty or unknown suffix (incl. camunda7's 'admin') falls back to the floor, with a warning", () => {
48
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
49
+ for (const suffix of ["", "admin"]) {
50
+ expect(analyticsToolsets.effective(suffix, { authenticated: true })).toEqual({
51
+ toolset: "read-only",
52
+ source: "fallback",
53
+ })
54
+ }
55
+ expect(warn).toHaveBeenCalledTimes(2)
56
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("[analytics]"))
57
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('Unknown toolset "admin"'))
58
+ })
59
+ })
60
+
61
+ describe("isAnalyticsToolset", () => {
62
+ it("accepts every declared name and nothing else", () => {
63
+ for (const toolset of ANALYTICS_TOOLSETS) {
64
+ expect(isAnalyticsToolset(toolset)).toBe(true)
65
+ }
66
+ expect(isAnalyticsToolset("admin")).toBe(false)
67
+ expect(isAnalyticsToolset("")).toBe(false)
68
+ })
69
+ })
70
+
71
+ describe("allowsDurableWrites", () => {
72
+ it("forbids writes when no toolset is configured — a missing toolset is the read-only floor", () => {
73
+ // Fail-closed: a direct `createPlugin` caller that passes no toolset gets
74
+ // the floor, never the full surface.
75
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
76
+ expect(allowsDurableWrites(undefined)).toBe(false)
77
+ expect(allowsDurableWrites()).toBe(false)
78
+ expect(warn).not.toHaveBeenCalled()
79
+ })
80
+
81
+ it("forbids writes in the module's read-only toolset", () => {
82
+ expect(allowsDurableWrites("read-only")).toBe(false)
83
+ })
84
+
85
+ it("permits writes in the standard toolset (the user's own settings save)", () => {
86
+ expect(allowsDurableWrites("standard")).toBe(true)
87
+ })
88
+
89
+ it("fails CLOSED on an unknown toolset name, with a warning", () => {
90
+ // A typo'd `analytics:standrad`, or camunda7's `operations`, must not
91
+ // grant the one write — unknown names degrade to `read-only`.
92
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
93
+ expect(allowsDurableWrites("operations")).toBe(false)
94
+ expect(warn).toHaveBeenCalledOnce()
95
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('Unknown toolset "operations"'))
96
+ })
97
+
98
+ it("decides by declared name, not by an ad-hoc string compare", () => {
99
+ // The regression this guards: a `toolset === "read-only"` check silently
100
+ // fails open for every name that is NOT that literal. Every declared
101
+ // toolset is a deliberate decision: only the floor forbids the write.
102
+ expect(ANALYTICS_TOOLSETS.map((toolset) => [toolset, allowsDurableWrites(toolset)])).toEqual([
103
+ ["read-only", false],
104
+ ["standard", true],
105
+ ])
106
+ })
107
+ })
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The analytics module's toolset vocabulary. Module peers own their toolset
3
+ * NAMES; the fail-closed SEMANTICS are shared (`createToolsetVocabulary` from
4
+ * `@miragon/pulse-widget-shell/server`):
5
+ *
6
+ * - `read-only` — the floor: every analytics read, no durable write (the
7
+ * settings save tool is not registered);
8
+ * - `standard` — the full analytics surface, including the caller's own
9
+ * settings save. Analytics has no admin tier: it reads Prometheus and never
10
+ * writes to an engine, so there is nothing wider to name.
11
+ *
12
+ * Which one a boot runs with (`MCP_ACTIVE_MODULES=analytics:<toolset>`):
13
+ *
14
+ * - no suffix → `read-only` on an unauthenticated boot, `standard` when the
15
+ * root installed OAuth;
16
+ * - an empty or unknown suffix → warns and degrades to `read-only` — a suffix
17
+ * always meant to restrict, so a typo never grants the write it tried to
18
+ * take away;
19
+ * - nothing ever resolves to "everything".
20
+ *
21
+ * The composition root resolves the suffix once per boot and hands the module
22
+ * a concrete name; a direct `createPlugin` caller that passes none gets the
23
+ * floor.
24
+ */
25
+ import { createToolsetVocabulary } from "@miragon/pulse-widget-shell/server"
26
+
27
+ export const ANALYTICS_TOOLSETS = ["read-only", "standard"] as const
28
+ export type AnalyticsToolset = (typeof ANALYTICS_TOOLSETS)[number]
29
+
30
+ /** The vocabulary the module declares to the composition root (`analyticsModule.toolsets`). */
31
+ export const analyticsToolsets = createToolsetVocabulary(
32
+ "analytics",
33
+ ANALYTICS_TOOLSETS,
34
+ "read-only",
35
+ {
36
+ authenticatedDefault: "standard",
37
+ },
38
+ )
39
+
40
+ export function isAnalyticsToolset(value: string): value is AnalyticsToolset {
41
+ return analyticsToolsets.isKnown(value)
42
+ }
43
+
44
+ /**
45
+ * Whether the toolset permits the module's durable writes
46
+ * (`analytics_save_settings`). Registered outside the tool registrar, that save
47
+ * has to gate itself — no registrar filter ever sees it.
48
+ *
49
+ * Decided by the vocabulary, never by an ad-hoc `toolset === "read-only"`
50
+ * compare (which fails open for every other name): a missing toolset resolves
51
+ * to the read-only floor, an unknown one warns and degrades to it, and only a
52
+ * toolset above the floor (`standard`) writes.
53
+ */
54
+ export function allowsDurableWrites(toolset?: string): boolean {
55
+ return analyticsToolsets.allowsDurableWrites(analyticsToolsets.resolve(toolset))
56
+ }
@@ -0,0 +1,123 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import type { MCPServer } from "mcp-use"
3
+ import type { PrometheusClient } from "@miragon/pulse-analytics-client"
4
+ import { createInMemoryProfileStore } from "@miragon/pulse-widget-shell/server"
5
+ import { createEngineScope } from "./engine-ids.js"
6
+ import { registerSettingsTools } from "./settings-tools.js"
7
+ import { registerWidgetTools } from "./widget-tools.js"
8
+
9
+ /**
10
+ * Structural guard for #322 U3 over EVERY analytics show tool (each tool with
11
+ * a view binding), the same rule camunda7's show tools follow (the kit's
12
+ * `createLocalizeViewFor`): the server titles a view only in a language the
13
+ * caller's profile names. "system" follows the host's locale, which the
14
+ * server never sees, so the view carries no title and the widget's own
15
+ * heading names it; an English title would sit over a German view. A new
16
+ * show tool that titles with `t(…)` instead of `title(…)` fails here.
17
+ */
18
+
19
+ type ToolCallback = (args: Record<string, unknown>, ctx?: unknown) => Promise<unknown>
20
+ type ViewResult = {
21
+ isError?: boolean
22
+ content?: unknown
23
+ structuredContent?: { title?: unknown; layout?: unknown }
24
+ }
25
+
26
+ /** Prometheus without samples: every view renders its empty state. */
27
+ const prometheus: PrometheusClient = { instant: () => Promise.resolve([]) }
28
+
29
+ /** The required arguments per show tool; the tool's own input schema fills in its defaults. */
30
+ const ARGS: Record<string, Record<string, unknown>> = {
31
+ analytics_show_bpmn_heatmap: { processDefinitionKey: "order" },
32
+ // Inside Prometheus' retention, with a post-deployment window to measure.
33
+ analytics_show_cluster_compare: {
34
+ deploymentTimestamp: new Date(Date.now() - 10 * 86_400_000).toISOString(),
35
+ },
36
+ analytics_show_version_compare: { processDefinitionKey: "order", versionA: 1, versionB: 2 },
37
+ analytics_show_engine_compare: {
38
+ processDefinitionKey: "order",
39
+ engineA: "prod-a",
40
+ engineB: "prod-b",
41
+ },
42
+ }
43
+
44
+ const callerCtx = (id: string) => ({ auth: { user: { id } } })
45
+
46
+ interface ToolDefinition {
47
+ name: string
48
+ view?: unknown
49
+ inputSchema: { parse: (args: unknown) => Record<string, unknown> }
50
+ }
51
+
52
+ /** The show tools, each called the way the server does: arguments parsed by its input schema. */
53
+ async function showTools() {
54
+ const tools = new Map<string, ToolCallback>()
55
+ const server = {
56
+ tool: (definition: ToolDefinition, callback: ToolCallback) => {
57
+ if (definition.view) {
58
+ tools.set(definition.name, (args, ctx) => callback(definition.inputSchema.parse(args), ctx))
59
+ }
60
+ },
61
+ } as unknown as MCPServer
62
+ const profileStore = createInMemoryProfileStore()
63
+ await profileStore.save("de-user", { language: "de" })
64
+ await profileStore.save("en-user", { language: "en" })
65
+ await profileStore.save("system-user", { language: "system" })
66
+ registerWidgetTools(server, prometheus, {
67
+ engineScope: createEngineScope(["prod-a", "prod-b"]),
68
+ profileStore,
69
+ })
70
+ registerSettingsTools(server, profileStore, "standard")
71
+ return tools
72
+ }
73
+
74
+ /** Each show tool's view title for one caller; every show tool must render its view. */
75
+ async function titlesFor(tools: Map<string, ToolCallback>, ctx: unknown) {
76
+ const titles = new Map<string, unknown>()
77
+ for (const [name, call] of tools) {
78
+ const result = (await call(ARGS[name] ?? {}, ctx)) as ViewResult
79
+ expect(result.isError, `${name}: ${JSON.stringify(result.content)}`).not.toBe(true)
80
+ expect(result.structuredContent?.layout, name).toBeTruthy()
81
+ titles.set(name, result.structuredContent?.title)
82
+ }
83
+ return titles
84
+ }
85
+
86
+ describe("analytics show tools title their view only in a language the profile names", () => {
87
+ it("covers every analytics show tool (the sweep is not vacuous)", async () => {
88
+ expect([...(await showTools()).keys()].sort()).toEqual([
89
+ "analytics_show_bpmn_heatmap",
90
+ "analytics_show_cluster_compare",
91
+ "analytics_show_dashboard",
92
+ "analytics_show_engine_compare",
93
+ "analytics_show_engine_landscape",
94
+ "analytics_show_failure_dashboard",
95
+ "analytics_show_settings",
96
+ "analytics_show_version_compare",
97
+ ])
98
+ })
99
+
100
+ it("no title for an anonymous caller or one whose profile follows the host", async () => {
101
+ const tools = await showTools()
102
+ // `{}`: a handler ctx without a caller identity (no OAuth).
103
+ for (const ctx of [{}, callerCtx("system-user"), callerCtx("nobody-saved-yet")]) {
104
+ const titles = await titlesFor(tools, ctx)
105
+ expect(Object.fromEntries([...titles].filter(([, title]) => title !== undefined))).toEqual({})
106
+ }
107
+ })
108
+
109
+ it("a named language titles every view, each in that language", async () => {
110
+ const tools = await showTools()
111
+ const de = await titlesFor(tools, callerCtx("de-user"))
112
+ const en = await titlesFor(tools, callerCtx("en-user"))
113
+ for (const [name] of tools) {
114
+ expect(de.get(name), name).toEqual(expect.any(String))
115
+ expect(en.get(name), name).toEqual(expect.any(String))
116
+ expect(de.get(name), name).not.toBe(en.get(name))
117
+ }
118
+ expect(de.get("analytics_show_settings")).toBe("Analyse-Einstellungen")
119
+ expect(en.get("analytics_show_settings")).toBe("Analytics settings")
120
+ expect(de.get("analytics_show_dashboard")).toBe("Prozessanalyse")
121
+ expect(en.get("analytics_show_dashboard")).toBe("Process analytics")
122
+ })
123
+ })
@@ -0,0 +1,170 @@
1
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
2
+ import type { MCPServer } from "mcp-use"
3
+ import type {
4
+ ClusterCompareResult,
5
+ PrometheusClient,
6
+ VersionCompareResult,
7
+ } from "@miragon/pulse-analytics-client"
8
+ import { createEngineScope } from "../engine-ids.js"
9
+ import type { ProfileSource } from "../server-locale.js"
10
+ import { registerComparisonWidgetTools } from "./comparisons.js"
11
+
12
+ type ToolResult = {
13
+ isError?: boolean
14
+ content: Array<{ type: string; text: string }>
15
+ structuredContent: { context: { stepData: { result: { data: unknown } } } }
16
+ }
17
+ type Handler = (args: Record<string, unknown>, ctx: unknown) => Promise<ToolResult>
18
+
19
+ const engineScope = createEngineScope(["prod-a", "prod-b"])
20
+
21
+ /** Registers the comparison widget tools on a stand-in server and returns their handlers. */
22
+ function handlers(ch: PrometheusClient): Map<string, Handler> {
23
+ const byName = new Map<string, Handler>()
24
+ const server = {
25
+ tool: (definition: { name: string }, handler: Handler) => {
26
+ byName.set(definition.name, handler)
27
+ },
28
+ } as unknown as MCPServer
29
+ registerComparisonWidgetTools({ server, ch, engineScope })
30
+ return byName
31
+ }
32
+
33
+ const dataOf = <T>(result: ToolResult) => result.structuredContent.context.stepData.result.data as T
34
+
35
+ /** Every query answers 20, so instance counts and durations are equal across versions. */
36
+ const ch: PrometheusClient = { instant: () => Promise.resolve([{ metric: {}, value: 20 }]) }
37
+
38
+ describe("analytics_show_version_compare", () => {
39
+ it("summarises the measured incident-rate delta per version (#337)", async () => {
40
+ const show = handlers(ch).get("analytics_show_version_compare")!
41
+ const result = await show(
42
+ { processDefinitionKey: "order", versionA: 1, versionB: 2, windowDays: 14 },
43
+ {},
44
+ )
45
+
46
+ expect(result.content[0].text).toContain("incident rate 0pp")
47
+ const data = dataOf<VersionCompareResult>(result)
48
+ expect(data.kpis.map((k) => k.incident_rate_pct)).toEqual([100, 100])
49
+ })
50
+
51
+ it("names the engines a fleet comparison aggregates (K33)", async () => {
52
+ const show = handlers(ch).get("analytics_show_version_compare")!
53
+ const result = await show(
54
+ { processDefinitionKey: "order", versionA: 1, versionB: 2, windowDays: 14 },
55
+ {},
56
+ )
57
+ expect(result.content[0].text).toContain(
58
+ 'across all 2 configured engines ("prod-a", "prod-b"), aggregated',
59
+ )
60
+ expect(dataOf<VersionCompareResult>(result).engines).toEqual(["prod-a", "prod-b"])
61
+ })
62
+ })
63
+
64
+ describe("analytics_show_cluster_compare — clamped windows (N79)", () => {
65
+ const NOW = Date.parse("2026-10-09T12:00:00Z")
66
+ beforeAll(() => {
67
+ vi.useFakeTimers({ toFake: ["Date"], now: NOW })
68
+ })
69
+ afterAll(() => {
70
+ vi.useRealTimers()
71
+ })
72
+
73
+ it("measures a recent deployment's post window up to now and says so", async () => {
74
+ const sent: string[] = []
75
+ const recording: PrometheusClient = {
76
+ instant: (q) => {
77
+ sent.push(q)
78
+ return Promise.resolve([{ metric: {}, value: 20 }])
79
+ },
80
+ }
81
+ const deployedTwoHoursAgo = new Date(NOW - 2 * 3_600_000).toISOString()
82
+ const result = await handlers(recording).get("analytics_show_cluster_compare")!(
83
+ { deploymentTimestamp: deployedTwoHoursAgo, windowBeforeDays: 7, windowAfterDays: 7 },
84
+ {},
85
+ )
86
+
87
+ // No `@` anchor beyond now — the post window is 2 h, not 7 d.
88
+ const anchors = sent.flatMap((q) => [...q.matchAll(/@ (\d+)/g)].map((m) => Number(m[1])))
89
+ expect(Math.max(...anchors)).toBe(NOW / 1000)
90
+ const data = dataOf<ClusterCompareResult>(result)
91
+ expect(data).toMatchObject({
92
+ partial: true,
93
+ requestedWindowDays: { before: 7, after: 7 },
94
+ windowDays: { before: 7, after: 0.08 },
95
+ })
96
+ expect(result.content[0].text).toContain("measured -7d / +0.08d, partial")
97
+ // 20 starts in 2 h vs 20 in 7 d is a huge rise PER DAY — never a flat 0 %.
98
+ expect(data.delta.started_per_day_delta_pct).toBe(8300)
99
+ })
100
+
101
+ it("refuses a deployment in the future instead of comparing an empty window", async () => {
102
+ const result = await handlers(ch).get("analytics_show_cluster_compare")!(
103
+ { deploymentTimestamp: new Date(NOW + 3_600_000).toISOString() },
104
+ {},
105
+ )
106
+ expect(result.isError).toBe(true)
107
+ expect(result.content[0].text).toContain("deploymentTimestamp lies in the future")
108
+ })
109
+ })
110
+
111
+ describe("analytics_show_engine_compare — engine scope (N138)", () => {
112
+ it("refuses an engine outside the configured ones", async () => {
113
+ const result = await handlers(ch).get("analytics_show_engine_compare")!(
114
+ { processDefinitionKey: "order", engineA: "prod-a", engineB: "tenant-x" },
115
+ {},
116
+ )
117
+ expect(result.isError).toBe(true)
118
+ expect(result.content[0].text).toContain('Unknown engine "tenant-x" in engineB')
119
+ })
120
+ })
121
+
122
+ describe("view titles (U3): the toolbar speaks the caller's language", () => {
123
+ const CTX = { auth: { user: { id: "user-1" } } }
124
+ const titledHandlers = (language: string) => {
125
+ const byName = new Map<string, Handler>()
126
+ const server = {
127
+ tool: (definition: { name: string }, handler: Handler) => {
128
+ byName.set(definition.name, handler)
129
+ },
130
+ } as unknown as MCPServer
131
+ const profileStore: ProfileSource = { get: () => Promise.resolve({ language }) }
132
+ registerComparisonWidgetTools({ server, ch, engineScope, profileStore })
133
+ return byName
134
+ }
135
+ const titleOf = (result: ToolResult) =>
136
+ (result.structuredContent as unknown as { title?: string }).title
137
+
138
+ const CALLS: Array<[string, Record<string, unknown>]> = [
139
+ // Handlers called directly get no schema defaults: pass the window.
140
+ [
141
+ "analytics_show_version_compare",
142
+ { processDefinitionKey: "o", versionA: 1, versionB: 2, windowDays: 14 },
143
+ ],
144
+ [
145
+ "analytics_show_engine_compare",
146
+ { processDefinitionKey: "o", engineA: "prod-a", engineB: "prod-b", windowDays: 14 },
147
+ ],
148
+ ["analytics_show_engine_landscape", {}],
149
+ ]
150
+ const titles = async (language: string) => {
151
+ const tools = titledHandlers(language)
152
+ return Promise.all(CALLS.map(([name, args]) => tools.get(name)!(args, CTX).then(titleOf)))
153
+ }
154
+
155
+ it("titles each comparison and the landscape in German", async () => {
156
+ expect(await titles("de")).toEqual([
157
+ "Versionsvergleich",
158
+ "Engine-Vergleich",
159
+ "Engine-übergreifende Übersicht",
160
+ ])
161
+ })
162
+
163
+ it("and in English, in sentence case", async () => {
164
+ expect(await titles("en")).toEqual([
165
+ "Version comparison",
166
+ "Engine comparison",
167
+ "Cross-engine landscape",
168
+ ])
169
+ })
170
+ })