@k-4u/resource-mapper-core 0.0.1

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 (250) hide show
  1. package/.aws-icons-last-updated +1 -0
  2. package/.svelte-kit/ambient.d.ts +263 -0
  3. package/.svelte-kit/generated/client/app.js +31 -0
  4. package/.svelte-kit/generated/client/matchers.js +1 -0
  5. package/.svelte-kit/generated/client/nodes/0.js +1 -0
  6. package/.svelte-kit/generated/client/nodes/1.js +1 -0
  7. package/.svelte-kit/generated/client/nodes/2.js +3 -0
  8. package/.svelte-kit/generated/client/nodes/3.js +3 -0
  9. package/.svelte-kit/generated/client-optimized/app.js +31 -0
  10. package/.svelte-kit/generated/client-optimized/matchers.js +1 -0
  11. package/.svelte-kit/generated/client-optimized/nodes/0.js +1 -0
  12. package/.svelte-kit/generated/client-optimized/nodes/1.js +1 -0
  13. package/.svelte-kit/generated/client-optimized/nodes/2.js +3 -0
  14. package/.svelte-kit/generated/client-optimized/nodes/3.js +3 -0
  15. package/.svelte-kit/generated/root.js +3 -0
  16. package/.svelte-kit/generated/root.svelte +68 -0
  17. package/.svelte-kit/generated/server/internal.js +53 -0
  18. package/.svelte-kit/non-ambient.d.ts +43 -0
  19. package/.svelte-kit/output/client/.vite/manifest.json +175 -0
  20. package/.svelte-kit/output/client/_app/immutable/assets/0.Czt_67iE.css +1 -0
  21. package/.svelte-kit/output/client/_app/immutable/assets/TeamContactCard.Dxj5nUCr.css +1 -0
  22. package/.svelte-kit/output/client/_app/immutable/assets/helpers.ysDrpaDf.css +1 -0
  23. package/.svelte-kit/output/client/_app/immutable/assets/libavoid.DQJapW5w.wasm +0 -0
  24. package/.svelte-kit/output/client/_app/immutable/chunks/BlLuv0eP.js +46 -0
  25. package/.svelte-kit/output/client/_app/immutable/chunks/CSBHmwYv.js +1 -0
  26. package/.svelte-kit/output/client/_app/immutable/chunks/CTCi5ueQ.js +1 -0
  27. package/.svelte-kit/output/client/_app/immutable/chunks/CfOzjaik.js +2 -0
  28. package/.svelte-kit/output/client/_app/immutable/chunks/D4PdvFNs.js +1 -0
  29. package/.svelte-kit/output/client/_app/immutable/chunks/DXgP-QUS.js +2 -0
  30. package/.svelte-kit/output/client/_app/immutable/chunks/DlbDC5An.js +1 -0
  31. package/.svelte-kit/output/client/_app/immutable/chunks/wRWe7aK9.js +1 -0
  32. package/.svelte-kit/output/client/_app/immutable/entry/app.ConrMuHl.js +2 -0
  33. package/.svelte-kit/output/client/_app/immutable/entry/start.Bm6FyGme.js +1 -0
  34. package/.svelte-kit/output/client/_app/immutable/nodes/0.d3cL-ETU.js +1 -0
  35. package/.svelte-kit/output/client/_app/immutable/nodes/1.D6z9rPGv.js +1 -0
  36. package/.svelte-kit/output/client/_app/immutable/nodes/2.CLD-8chl.js +1 -0
  37. package/.svelte-kit/output/client/_app/immutable/nodes/3.DXYeBoel.js +1 -0
  38. package/.svelte-kit/output/client/_app/version.json +1 -0
  39. package/.svelte-kit/output/client/libavoid.wasm +0 -0
  40. package/.svelte-kit/output/client/static/robots.txt +3 -0
  41. package/.svelte-kit/output/prerendered/dependencies/_app/env.js +1 -0
  42. package/.svelte-kit/output/server/.vite/manifest.json +224 -0
  43. package/.svelte-kit/output/server/_app/immutable/assets/LoadingOverlay.DBbe6V8W.css +1 -0
  44. package/.svelte-kit/output/server/_app/immutable/assets/_layout.Czt_67iE.css +1 -0
  45. package/.svelte-kit/output/server/_app/immutable/assets/_page.D9P41uDZ.css +1 -0
  46. package/.svelte-kit/output/server/chunks/ErrorDisplay.js +59 -0
  47. package/.svelte-kit/output/server/chunks/LoadingOverlay.js +12 -0
  48. package/.svelte-kit/output/server/chunks/LoadingOverlay.svelte_svelte_type_style_lang.js +1671 -0
  49. package/.svelte-kit/output/server/chunks/connections.js +33 -0
  50. package/.svelte-kit/output/server/chunks/diagram.js +7 -0
  51. package/.svelte-kit/output/server/chunks/environment.js +34 -0
  52. package/.svelte-kit/output/server/chunks/equality.js +57 -0
  53. package/.svelte-kit/output/server/chunks/exports.js +174 -0
  54. package/.svelte-kit/output/server/chunks/false.js +4 -0
  55. package/.svelte-kit/output/server/chunks/index.js +59 -0
  56. package/.svelte-kit/output/server/chunks/index2.js +2939 -0
  57. package/.svelte-kit/output/server/chunks/index3.js +20 -0
  58. package/.svelte-kit/output/server/chunks/internal.js +1017 -0
  59. package/.svelte-kit/output/server/chunks/shared.js +770 -0
  60. package/.svelte-kit/output/server/chunks/utils.js +43 -0
  61. package/.svelte-kit/output/server/entries/pages/_error.svelte.js +64 -0
  62. package/.svelte-kit/output/server/entries/pages/_layout.svelte.js +65 -0
  63. package/.svelte-kit/output/server/entries/pages/_page.svelte.js +3991 -0
  64. package/.svelte-kit/output/server/entries/pages/_page.ts.js +30 -0
  65. package/.svelte-kit/output/server/entries/pages/group/_groupId_/_page.svelte.js +67 -0
  66. package/.svelte-kit/output/server/entries/pages/group/_groupId_/_page.ts.js +47 -0
  67. package/.svelte-kit/output/server/index.js +3747 -0
  68. package/.svelte-kit/output/server/internal.js +13 -0
  69. package/.svelte-kit/output/server/manifest-full.js +47 -0
  70. package/.svelte-kit/output/server/manifest.js +47 -0
  71. package/.svelte-kit/output/server/nodes/0.js +8 -0
  72. package/.svelte-kit/output/server/nodes/1.js +8 -0
  73. package/.svelte-kit/output/server/nodes/2.js +10 -0
  74. package/.svelte-kit/output/server/nodes/3.js +10 -0
  75. package/.svelte-kit/output/server/remote-entry.js +557 -0
  76. package/.svelte-kit/tsconfig.json +61 -0
  77. package/.svelte-kit/types/route_meta_data.json +8 -0
  78. package/.svelte-kit/types/src/routes/$types.d.ts +26 -0
  79. package/.svelte-kit/types/src/routes/group/[groupId]/$types.d.ts +21 -0
  80. package/.svelte-kit/types/src/routes/group/[groupId]/proxy+page.ts +49 -0
  81. package/.svelte-kit/types/src/routes/proxy+page.ts +33 -0
  82. package/build/_app/env.js +1 -0
  83. package/build/_app/env.js.br +1 -0
  84. package/build/_app/env.js.gz +0 -0
  85. package/build/_app/immutable/assets/0.Czt_67iE.css +1 -0
  86. package/build/_app/immutable/assets/0.Czt_67iE.css.br +0 -0
  87. package/build/_app/immutable/assets/0.Czt_67iE.css.gz +0 -0
  88. package/build/_app/immutable/assets/TeamContactCard.Dxj5nUCr.css +1 -0
  89. package/build/_app/immutable/assets/TeamContactCard.Dxj5nUCr.css.br +0 -0
  90. package/build/_app/immutable/assets/TeamContactCard.Dxj5nUCr.css.gz +0 -0
  91. package/build/_app/immutable/assets/helpers.ysDrpaDf.css +1 -0
  92. package/build/_app/immutable/assets/helpers.ysDrpaDf.css.br +0 -0
  93. package/build/_app/immutable/assets/helpers.ysDrpaDf.css.gz +0 -0
  94. package/build/_app/immutable/assets/libavoid.DQJapW5w.wasm +0 -0
  95. package/build/_app/immutable/assets/libavoid.DQJapW5w.wasm.br +0 -0
  96. package/build/_app/immutable/assets/libavoid.DQJapW5w.wasm.gz +0 -0
  97. package/build/_app/immutable/chunks/BlLuv0eP.js +46 -0
  98. package/build/_app/immutable/chunks/BlLuv0eP.js.br +0 -0
  99. package/build/_app/immutable/chunks/BlLuv0eP.js.gz +0 -0
  100. package/build/_app/immutable/chunks/CSBHmwYv.js +1 -0
  101. package/build/_app/immutable/chunks/CSBHmwYv.js.br +0 -0
  102. package/build/_app/immutable/chunks/CSBHmwYv.js.gz +0 -0
  103. package/build/_app/immutable/chunks/CTCi5ueQ.js +1 -0
  104. package/build/_app/immutable/chunks/CTCi5ueQ.js.br +0 -0
  105. package/build/_app/immutable/chunks/CTCi5ueQ.js.gz +0 -0
  106. package/build/_app/immutable/chunks/CfOzjaik.js +2 -0
  107. package/build/_app/immutable/chunks/CfOzjaik.js.br +0 -0
  108. package/build/_app/immutable/chunks/CfOzjaik.js.gz +0 -0
  109. package/build/_app/immutable/chunks/D4PdvFNs.js +1 -0
  110. package/build/_app/immutable/chunks/D4PdvFNs.js.br +0 -0
  111. package/build/_app/immutable/chunks/D4PdvFNs.js.gz +0 -0
  112. package/build/_app/immutable/chunks/DXgP-QUS.js +2 -0
  113. package/build/_app/immutable/chunks/DXgP-QUS.js.br +0 -0
  114. package/build/_app/immutable/chunks/DXgP-QUS.js.gz +0 -0
  115. package/build/_app/immutable/chunks/DlbDC5An.js +1 -0
  116. package/build/_app/immutable/chunks/DlbDC5An.js.br +0 -0
  117. package/build/_app/immutable/chunks/DlbDC5An.js.gz +0 -0
  118. package/build/_app/immutable/chunks/wRWe7aK9.js +1 -0
  119. package/build/_app/immutable/chunks/wRWe7aK9.js.br +0 -0
  120. package/build/_app/immutable/chunks/wRWe7aK9.js.gz +0 -0
  121. package/build/_app/immutable/entry/app.ConrMuHl.js +2 -0
  122. package/build/_app/immutable/entry/app.ConrMuHl.js.br +0 -0
  123. package/build/_app/immutable/entry/app.ConrMuHl.js.gz +0 -0
  124. package/build/_app/immutable/entry/start.Bm6FyGme.js +1 -0
  125. package/build/_app/immutable/entry/start.Bm6FyGme.js.br +2 -0
  126. package/build/_app/immutable/entry/start.Bm6FyGme.js.gz +0 -0
  127. package/build/_app/immutable/nodes/0.d3cL-ETU.js +1 -0
  128. package/build/_app/immutable/nodes/0.d3cL-ETU.js.br +0 -0
  129. package/build/_app/immutable/nodes/0.d3cL-ETU.js.gz +0 -0
  130. package/build/_app/immutable/nodes/1.D6z9rPGv.js +1 -0
  131. package/build/_app/immutable/nodes/1.D6z9rPGv.js.br +0 -0
  132. package/build/_app/immutable/nodes/1.D6z9rPGv.js.gz +0 -0
  133. package/build/_app/immutable/nodes/2.CLD-8chl.js +1 -0
  134. package/build/_app/immutable/nodes/2.CLD-8chl.js.br +0 -0
  135. package/build/_app/immutable/nodes/2.CLD-8chl.js.gz +0 -0
  136. package/build/_app/immutable/nodes/3.DXYeBoel.js +1 -0
  137. package/build/_app/immutable/nodes/3.DXYeBoel.js.br +0 -0
  138. package/build/_app/immutable/nodes/3.DXYeBoel.js.gz +0 -0
  139. package/build/_app/version.json +1 -0
  140. package/build/_app/version.json.br +0 -0
  141. package/build/_app/version.json.gz +0 -0
  142. package/build/index.html +34 -0
  143. package/build/index.html.br +0 -0
  144. package/build/index.html.gz +0 -0
  145. package/build/libavoid.wasm +0 -0
  146. package/build/libavoid.wasm.br +0 -0
  147. package/build/libavoid.wasm.gz +0 -0
  148. package/build/static/robots.txt +3 -0
  149. package/coverage/coverage-final.json +6 -0
  150. package/coverage/coverage-summary.json +7 -0
  151. package/coverage/junit.xml +57 -0
  152. package/coverage/lcov-report/base.css +224 -0
  153. package/coverage/lcov-report/block-navigation.js +87 -0
  154. package/coverage/lcov-report/favicon.png +0 -0
  155. package/coverage/lcov-report/index.html +131 -0
  156. package/coverage/lcov-report/prettify.css +1 -0
  157. package/coverage/lcov-report/prettify.js +2 -0
  158. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  159. package/coverage/lcov-report/sorter.js +210 -0
  160. package/coverage/lcov-report/stores/index.html +116 -0
  161. package/coverage/lcov-report/stores/routingStore.ts.html +781 -0
  162. package/coverage/lcov-report/utils/flow/helpers.ts.html +127 -0
  163. package/coverage/lcov-report/utils/flow/index.html +161 -0
  164. package/coverage/lcov-report/utils/flow/layout.ts.html +805 -0
  165. package/coverage/lcov-report/utils/flow/serviceIds.ts.html +97 -0
  166. package/coverage/lcov-report/utils/flow/servicesGraph.ts.html +859 -0
  167. package/coverage/lcov.info +646 -0
  168. package/coverage/test-results.json +1 -0
  169. package/data/services/api/api-gateway.yaml +18 -0
  170. package/data/services/api/group-info.yaml +7 -0
  171. package/data/services/api/lambda-orders.yaml +21 -0
  172. package/data/services/api/lambda-products.yaml +15 -0
  173. package/data/services/api/lambda-users.yaml +15 -0
  174. package/data/services/compute/alb.yaml +15 -0
  175. package/data/services/compute/ecs-inventory.yaml +16 -0
  176. package/data/services/compute/ecs-notification.yaml +15 -0
  177. package/data/services/compute/group-info.yaml +7 -0
  178. package/data/services/data/dynamodb-notifications.yaml +12 -0
  179. package/data/services/data/dynamodb-orders.yaml +9 -0
  180. package/data/services/data/dynamodb-products.yaml +9 -0
  181. package/data/services/data/dynamodb-users.yaml +9 -0
  182. package/data/services/data/group-info.yaml +7 -0
  183. package/data/services/data/rds-postgres.yaml +9 -0
  184. package/data/services/data/redis.yaml +10 -0
  185. package/data/services/frontend/cloudfront.yaml +12 -0
  186. package/data/services/frontend/group-info.yaml +7 -0
  187. package/data/services/frontend/route53.yaml +15 -0
  188. package/data/services/frontend/s3-website.yaml +9 -0
  189. package/data/teams/cloud-shepherds.yaml +15 -0
  190. package/data/teams/data-wizards.yaml +15 -0
  191. package/data/teams/interface-architects.yaml +19 -0
  192. package/e2e/demo.test.ts +54 -0
  193. package/e2e/header-toolbar.simple.spec.ts +0 -0
  194. package/e2e/header-toolbar.spec.ts +53 -0
  195. package/e2e/layout.spec.ts +30 -0
  196. package/package.json +69 -0
  197. package/playwright.config.ts +10 -0
  198. package/plugins/mapper-data-plugin.ts +32 -0
  199. package/project.json +23 -0
  200. package/src/app.css +125 -0
  201. package/src/app.d.ts +31 -0
  202. package/src/app.html +11 -0
  203. package/src/lib/assets/favicon.svg +19 -0
  204. package/src/lib/components/EmptyState.svelte +37 -0
  205. package/src/lib/components/ErrorDisplay.svelte +82 -0
  206. package/src/lib/components/FlowCanvas.svelte +223 -0
  207. package/src/lib/components/GenericSidebarCard.svelte +44 -0
  208. package/src/lib/components/GroupDetailSidebar.svelte +31 -0
  209. package/src/lib/components/Header.svelte +57 -0
  210. package/src/lib/components/Legend.svelte +25 -0
  211. package/src/lib/components/LoadingOverlay.svelte +42 -0
  212. package/src/lib/components/LoadingSpinner.svelte +10 -0
  213. package/src/lib/components/ServiceDetailSidebar.svelte +90 -0
  214. package/src/lib/components/TeamContactCard.svelte +166 -0
  215. package/src/lib/components/flow/ExternalNode.svelte +45 -0
  216. package/src/lib/components/flow/MainGroupNode.svelte +24 -0
  217. package/src/lib/components/flow/ServiceGroupNode.svelte +17 -0
  218. package/src/lib/components/flow/ServiceNode.svelte +40 -0
  219. package/src/lib/components/flow/SnakeEdge.svelte +206 -0
  220. package/src/lib/components/flow/index.ts +6 -0
  221. package/src/lib/components/index.ts +12 -0
  222. package/src/lib/data/connections.ts +26 -0
  223. package/src/lib/data/groups.ts +11 -0
  224. package/src/lib/data/services.ts +12 -0
  225. package/src/lib/data/teams.ts +11 -0
  226. package/src/lib/index.ts +1 -0
  227. package/src/lib/state/theme.svelte.ts +21 -0
  228. package/src/lib/stores/diagram.ts +6 -0
  229. package/src/lib/stores/routingStore.test.ts +133 -0
  230. package/src/lib/stores/routingStore.ts +232 -0
  231. package/src/lib/utils/awsIcons.ts +117 -0
  232. package/src/lib/utils/flow/groupOverviewGraph.ts +73 -0
  233. package/src/lib/utils/flow/helpers.ts +14 -0
  234. package/src/lib/utils/flow/layout.test.ts +271 -0
  235. package/src/lib/utils/flow/layout.ts +240 -0
  236. package/src/lib/utils/flow/serviceIds.ts +5 -0
  237. package/src/lib/utils/flow/servicesGraph.test.ts +119 -0
  238. package/src/lib/utils/flow/servicesGraph.ts +258 -0
  239. package/src/routes/+error.svelte +36 -0
  240. package/src/routes/+layout.svelte +27 -0
  241. package/src/routes/+page.svelte +81 -0
  242. package/src/routes/+page.ts +31 -0
  243. package/src/routes/group/[groupId]/+page.svelte +102 -0
  244. package/src/routes/group/[groupId]/+page.ts +48 -0
  245. package/src/routes/layout.css +0 -0
  246. package/static/static/robots.txt +3 -0
  247. package/svelte.config.js +28 -0
  248. package/tailwind.config.js +12 -0
  249. package/tsconfig.json +22 -0
  250. package/vite.config.ts +81 -0
@@ -0,0 +1,271 @@
1
+ import {describe, it, expect} from 'vitest'
2
+ import type {Edge, Node} from '@xyflow/svelte'
3
+ import {layoutFlowGraph, calculateEdgeOffset, OFFSET_STEP} from './layout'
4
+ import type {FlowEdgeData, FlowGraphInput, FlowNodeData} from '$shared/flow-types'
5
+
6
+ // Basic helpers to build nodes/edges with minimal required data
7
+ function serviceNode(id: string, label = id, serviceType?: string, position = {x: 0, y: 0}): Node<FlowNodeData> {
8
+ return {
9
+ id,
10
+ data: {label, kind: 'service', serviceType},
11
+ position,
12
+ type: 'service'
13
+ } as unknown as Node<FlowNodeData>
14
+ }
15
+
16
+ function groupNode(id: string, label = id, position = {x: 0, y: 0}): Node<FlowNodeData> {
17
+ return {
18
+ id,
19
+ data: {label, kind: 'group'},
20
+ position,
21
+ type: 'serviceGroup'
22
+ } as unknown as Node<FlowNodeData>
23
+ }
24
+
25
+ function edge(id: string, source: string, target: string, data: FlowEdgeData = {}): Edge<FlowEdgeData> {
26
+ return {
27
+ id,
28
+ source,
29
+ target,
30
+ data
31
+ } as Edge<FlowEdgeData>
32
+ }
33
+
34
+ // Silence verbose debug logs during tests
35
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
36
+ const noop = () => {
37
+ }
38
+ // @ts-expect-error allow override in test env
39
+ console.debug = noop
40
+ // @ts-expect-error allow override in test env
41
+ console.log = noop
42
+
43
+ describe('layoutFlowGraph', () => {
44
+ it('returns positions for nodes and preserves signature', async () => {
45
+ const input: FlowGraphInput = {
46
+ groupNodes: [],
47
+ serviceNodes: [serviceNode('s1', 'Service 1')],
48
+ edges: [],
49
+ signature: 'sig-1'
50
+ }
51
+
52
+ const out = await layoutFlowGraph(input)
53
+
54
+ expect(out.signature).toBe(input.signature)
55
+ expect(out.nodes.length).toBeGreaterThanOrEqual(1)
56
+
57
+ const n = out.nodes.find((x) => x.id === 's1')
58
+ expect(n).toBeTruthy()
59
+ expect(n?.position?.x).toBeDefined()
60
+ expect(n?.position?.y).toBeDefined()
61
+ })
62
+
63
+ it('keeps nodes and edges ids and data', async () => {
64
+ const input: FlowGraphInput = {
65
+ groupNodes: [],
66
+ serviceNodes: [serviceNode('a', 'Service A', 'lambda'), serviceNode('b', 'Service B', 'sqs')],
67
+ edges: [edge('e1', 'a', 'b', {label: 'A->B', direction: 'outgoing'})],
68
+ signature: 'sig-2'
69
+ }
70
+
71
+ const out = await layoutFlowGraph(input)
72
+
73
+ // Nodes should be present
74
+ const nodeA = out.nodes.find((n) => n.id === 'a')
75
+ const nodeB = out.nodes.find((n) => n.id === 'b')
76
+ expect(nodeA).toBeTruthy()
77
+ expect(nodeB).toBeTruthy()
78
+
79
+ // Verify component-specific data is preserved
80
+ expect(nodeA?.data.label).toBe('Service A')
81
+ expect(nodeA?.data.serviceType).toBe('lambda')
82
+ expect(nodeB?.data.serviceType).toBe('sqs')
83
+
84
+ // Edge should be preserved with same ids and data
85
+ expect(out.edges.length).toBe(1)
86
+ const e = out.edges[0]
87
+ expect(e.id).toBe('e1')
88
+ expect(e.source).toBe('a')
89
+ expect(e.target).toBe('b')
90
+ expect(e.data?.label).toBe('A->B')
91
+ expect(e.data?.direction).toBe('outgoing')
92
+ })
93
+
94
+ it('handles a simple group with a child service node', async () => {
95
+ const g = groupNode('g1', 'Group 1')
96
+ const s = {...serviceNode('s1', 'Service in group'), parentId: 'g1'} as unknown as Node<FlowNodeData>
97
+
98
+ const input: FlowGraphInput = {
99
+ groupNodes: [g],
100
+ serviceNodes: [s],
101
+ edges: [],
102
+ signature: 'sig-3'
103
+ }
104
+
105
+ const out = await layoutFlowGraph(input)
106
+
107
+ // Expect both the group and the service node to be present in flattened output
108
+ const groupOut = out.nodes.find((n) => n.id === 'g1')
109
+ const childOut = out.nodes.find((n) => n.id === 's1')
110
+
111
+ expect(groupOut).toBeTruthy()
112
+ expect(childOut).toBeTruthy()
113
+
114
+ // Both should have positions
115
+ expect(groupOut?.position?.x).toBeDefined()
116
+ expect(groupOut?.position?.y).toBeDefined()
117
+ expect(childOut?.position?.x).toBeDefined()
118
+ expect(childOut?.position?.y).toBeDefined()
119
+ })
120
+ })
121
+
122
+ describe('calculateEdgeOffset', () => {
123
+ it('returns 0 for a single edge', () => {
124
+ const nodes: Node<FlowNodeData>[] = [serviceNode('s1'), serviceNode('t1')]
125
+ const edges: Edge<FlowEdgeData>[] = [
126
+ {id: 'e1', source: 's1', target: 't1', sourceHandle: 'output', targetHandle: 'input'} as Edge<FlowEdgeData>
127
+ ]
128
+
129
+ expect(calculateEdgeOffset('e1', nodes, edges, true)).toBe(0)
130
+ expect(calculateEdgeOffset('e1', nodes, edges, false)).toBe(0)
131
+ })
132
+
133
+ it('spreads multiple edges and sorts them by target position', () => {
134
+ const nodes: Node<FlowNodeData>[] = [
135
+ serviceNode('s1', 'Source', undefined, {x: 0, y: 100}),
136
+ serviceNode('t1', 'Top Target', undefined, {x: 200, y: 0}),
137
+ serviceNode('t2', 'Bottom Target', undefined, {x: 200, y: 200})
138
+ ]
139
+ const edges: Edge<FlowEdgeData>[] = [
140
+ {id: 'e_to_t1', source: 's1', target: 't1', sourceHandle: 'output'} as Edge<FlowEdgeData>,
141
+ {id: 'e_to_t2', source: 's1', target: 't2', sourceHandle: 'output'} as Edge<FlowEdgeData>
142
+ ]
143
+
144
+ // e_to_t1 connects to t1 (y=0)
145
+ // e_to_t2 connects to t2 (y=200)
146
+ // Sorted order: e_to_t1, e_to_t2
147
+ // index for e_to_t1 = 0. index for e_to_t2 = 1.
148
+ // offsets = (index - 0.5) * OFFSET_STEP
149
+
150
+ expect(calculateEdgeOffset('e_to_t1', nodes, edges, true)).toBe(-OFFSET_STEP / 2)
151
+ expect(calculateEdgeOffset('e_to_t2', nodes, edges, true)).toBe(OFFSET_STEP / 2)
152
+ })
153
+
154
+ it('spreads three edges correctly', () => {
155
+ const nodes: Node<FlowNodeData>[] = [
156
+ serviceNode('s1', 'Source', undefined, {x: 0, y: 100}),
157
+ serviceNode('t1', 'T1', undefined, {x: 200, y: 0}),
158
+ serviceNode('t2', 'T2', undefined, {x: 200, y: 100}),
159
+ serviceNode('t3', 'T3', undefined, {x: 200, y: 200})
160
+ ]
161
+ const edges: Edge<FlowEdgeData>[] = [
162
+ {id: 'e1', source: 's1', target: 't1', sourceHandle: 'output'} as Edge<FlowEdgeData>,
163
+ {id: 'e2', source: 's1', target: 't2', sourceHandle: 'output'} as Edge<FlowEdgeData>,
164
+ {id: 'e3', source: 's1', target: 't3', sourceHandle: 'output'} as Edge<FlowEdgeData>
165
+ ]
166
+
167
+ // Sorted order: e1 (y=0), e2 (y=100), e3 (y=200)
168
+ // index: 0, 1, 2. (n-1)/2 = 1.
169
+ // offsets: (0-1)*OFFSET_STEP, (1-1)*OFFSET_STEP, (2-1)*OFFSET_STEP
170
+
171
+ expect(calculateEdgeOffset('e1', nodes, edges, true)).toBe(-OFFSET_STEP)
172
+ expect(calculateEdgeOffset('e2', nodes, edges, true)).toBe(0)
173
+ expect(calculateEdgeOffset('e3', nodes, edges, true)).toBe(OFFSET_STEP)
174
+ })
175
+
176
+ it('correctly calculates offsets for nodes in groups vs orphans using absolute coordinates', () => {
177
+ // Node 1 (orphan) at x=10, y=60
178
+ // Group 1 at x=422, y=67
179
+ // Node 2 (child of Group 1) at x=10, y=160 (relative) -> Abs: y = 67 + 160 = 227
180
+ // Node 3 (child of Group 1) at x=350, y=260 (relative) -> Abs: y = 67 + 260 = 327
181
+ // Edges: Node 1 -> Node 3 (e1), Node 2 -> Node 3 (e2)
182
+ // Both connect to Node 3's input handle (target).
183
+ const nodes: Node<FlowNodeData>[] = [
184
+ serviceNode('n1', 'Orphan', undefined, {x: 10, y: 60}),
185
+ groupNode('g1', 'Group 1', {x: 422, y: 67}),
186
+ {...serviceNode('n2', 'Child 2', undefined, {x: 10, y: 160}), parentId: 'g1'} as Node<FlowNodeData>,
187
+ {...serviceNode('n3', 'Target 3', undefined, {x: 350, y: 260}), parentId: 'g1'} as Node<FlowNodeData>
188
+ ]
189
+ const edges: Edge<FlowEdgeData>[] = [
190
+ {id: 'e1', source: 'n1', target: 'n3', targetHandle: 'input'} as Edge<FlowEdgeData>,
191
+ {id: 'e2', source: 'n2', target: 'n3', targetHandle: 'input'} as Edge<FlowEdgeData>
192
+ ]
193
+
194
+ // Absolute positions:
195
+ // n1 (abs y=60)
196
+ // n2 (abs y=67+160=227)
197
+ // Both are sources of Node 3.
198
+ // Order should be e1 (60), e2 (227).
199
+ // Offset for e1: -4, Offset for e2: 4.
200
+ // This confirms n1's edge is "above" n2's edge (smaller Y offset).
201
+ // The user said: "verify if the connection coming from the second node is above of the connection coming from the first node."
202
+ // Maybe the user meant if it WAS above? No, I'll just check if e1 is above e2.
203
+
204
+ const offset1 = calculateEdgeOffset('e1', nodes, edges, false) // target offset for e1
205
+ const offset2 = calculateEdgeOffset('e2', nodes, edges, false) // target offset for e2
206
+
207
+ // Expected: offset1 < offset2 because n1 (abs 60) < n2 (abs 227)
208
+ expect(offset1).toBe(-OFFSET_STEP / 2)
209
+ expect(offset2).toBe(OFFSET_STEP / 2)
210
+
211
+ // Now if the parent was at y=-200
212
+ // n2 (abs y=-200+160=-40)
213
+ // n1 (abs y=60)
214
+ // Now n2 is HIGHER than n1.
215
+ // Order should be e2 (-40), e1 (60).
216
+ // Offset for e2: -4, Offset for e1: 4.
217
+ const nodes2: Node<FlowNodeData>[] = [
218
+ serviceNode('n1', 'Orphan', undefined, {x: 10, y: 60}),
219
+ groupNode('g1', 'Group 1', {x: 422, y: -200}),
220
+ {...serviceNode('n2', 'Child 2', undefined, {x: 10, y: 160}), parentId: 'g1'} as Node<FlowNodeData>,
221
+ {...serviceNode('n3', 'Target 3', undefined, {x: 350, y: 260}), parentId: 'g1'} as Node<FlowNodeData>
222
+ ]
223
+
224
+ const offset1_neg = calculateEdgeOffset('e1', nodes2, edges, false)
225
+ const offset2_neg = calculateEdgeOffset('e2', nodes2, edges, false)
226
+
227
+ // Expected: offset2_neg < offset1_neg because n2 (abs -40) < n1 (abs 60)
228
+ expect(offset2_neg).toBe(-OFFSET_STEP / 2)
229
+ expect(offset1_neg).toBe(OFFSET_STEP / 2)
230
+ })
231
+
232
+ it('correctly calculates absolute coordinates for deeply nested nodes', () => {
233
+ const nodes: Node<FlowNodeData>[] = [
234
+ groupNode('g1', 'G1', {x: 100, y: 100}),
235
+ {...groupNode('g2', 'G2', {x: 50, y: 50}), parentId: 'g1'} as Node<FlowNodeData>,
236
+ {...serviceNode('n1', 'N1', undefined, {x: 10, y: 10}), parentId: 'g2'} as Node<FlowNodeData>
237
+ ]
238
+ // n1 abs position should be: g1(100,100) + g2(50,50) + n1(10,10) = (160, 160)
239
+
240
+ // We'll create another node n2 and an edge n1 -> n2.
241
+ const nodesFull = [
242
+ ...nodes,
243
+ serviceNode('n2', 'N2', undefined, {x: 500, y: 500})
244
+ ]
245
+ const edges = [
246
+ {id: 'e1', source: 'n1', target: 'n2', sourceHandle: 'output', targetHandle: 'input'} as Edge<FlowEdgeData>,
247
+ {id: 'e2', source: 'n3', target: 'n4', sourceHandle: 'output', targetHandle: 'input'} as Edge<FlowEdgeData>
248
+ ]
249
+
250
+ // If n1 is at (160, 160) and n2 is at (500, 500)
251
+ const n3 = serviceNode('n3', 'N3', undefined, {x: 160, y: 170})
252
+ const n4 = serviceNode('n4', 'N4', undefined, {x: 500, y: 510})
253
+ const nodesExtra = [...nodesFull, n3, n4]
254
+
255
+ // e1 should be compared with other edges if they share handle or nodes
256
+ // Let's use same handle for easier testing of ordering
257
+ const edgesShared = [
258
+ {id: 'e1', source: 'n1', target: 'n2', sourceHandle: 'output', targetHandle: 'input'} as Edge<FlowEdgeData>,
259
+ {id: 'e2', source: 'n3', target: 'n2', sourceHandle: 'output', targetHandle: 'input'} as Edge<FlowEdgeData>
260
+ ]
261
+
262
+ // n1 abs y=160. n3 abs y=170.
263
+ // e1 should get -4, e2 should get 4.
264
+
265
+ const o1 = calculateEdgeOffset('e1', nodesExtra, edgesShared, false)
266
+ const o2 = calculateEdgeOffset('e2', nodesExtra, edgesShared, false)
267
+
268
+ expect(o1).toBe(-OFFSET_STEP / 2)
269
+ expect(o2).toBe(OFFSET_STEP / 2)
270
+ })
271
+ })
@@ -0,0 +1,240 @@
1
+ import ELK from 'elkjs/lib/elk.bundled.js';
2
+ import {type Edge, type Node} from '@xyflow/svelte';
3
+ import type {FlowEdgeData, FlowGraphInput, FlowGraphOutput, FlowNodeData} from '$shared/flow-types';
4
+ import type {ElkNode} from "elkjs/lib/elk-api";
5
+
6
+ const elk = new ELK();
7
+ const PADDING = 10;
8
+ export const OFFSET_STEP = 20;
9
+
10
+ /**
11
+ * Helper to calculate absolute position of a node by traversing its parent chain.
12
+ */
13
+ export function getAbsolutePosition(nodeId: string, nodes: Node<FlowNodeData>[]): { x: number, y: number } {
14
+ const node = nodes.find(n => n.id === nodeId);
15
+ if (!node) return {x: 0, y: 0};
16
+
17
+ let x = node.position?.x ?? 0;
18
+ let y = node.position?.y ?? 0;
19
+
20
+ if (node.parentId) {
21
+ const parentPos = getAbsolutePosition(node.parentId, nodes);
22
+ x += parentPos.x;
23
+ y += parentPos.y;
24
+ }
25
+
26
+ return {x, y};
27
+ }
28
+
29
+ /**
30
+ * Calculates a deterministic offset for an edge to prevent overlapping.
31
+ * TODO: Replace this method with elk's built-in edge routing and spacing options once we can guarantee it works well in all cases.
32
+ * This probably means that we'll have to replace it once libavoid is integrated into elk: https://github.com/kieler/elkjs/issues/210
33
+ * This function handles two types of offsets:
34
+ * 1. Handle Offset: For multiple edges sharing the same handle on a node.
35
+ * 2. Trunk Offset: For edges that don't share a handle but might have overlapping
36
+ * paths (trunks) in the same "lane".
37
+ */
38
+ export function calculateEdgeOffset(
39
+ edgeId: string,
40
+ nodes: Node<FlowNodeData>[],
41
+ edges: Edge<FlowEdgeData>[],
42
+ isSource: boolean
43
+ ): number {
44
+ const edge = edges.find(e => e.id === edgeId);
45
+ if (!edge) return 0;
46
+
47
+ const nodeId = isSource ? edge.source : edge.target;
48
+ const handleId = isSource ? edge.sourceHandle : edge.targetHandle;
49
+
50
+ const handle = isSource ? edge.sourceHandle : edge.targetHandle;
51
+ const isVertical = handle?.toLowerCase().includes('top') || handle?.toLowerCase().includes('bottom');
52
+
53
+ let finalSiblings: Edge<FlowEdgeData>[] = [];
54
+ let usedAreaFallback = false;
55
+
56
+ // HANDLE OFFSET: Siblings on the same node and same handle
57
+ const siblings = edges.filter(e =>
58
+ (isSource ? e.source : e.target) === nodeId &&
59
+ (isSource ? e.sourceHandle : e.targetHandle) === handleId
60
+ );
61
+
62
+ if (siblings.length <= 1) {
63
+ // Fallback for bidirectional or single edges between same nodes
64
+ usedAreaFallback = true;
65
+ const otherId = isSource ? edge.target : edge.source;
66
+ finalSiblings = edges.filter(e => {
67
+ const isSamePair = (e.source === nodeId && e.target === otherId) ||
68
+ (e.source === otherId && e.target === nodeId);
69
+ return isSamePair && (e.source === nodeId || e.target === nodeId);
70
+ });
71
+ } else {
72
+ finalSiblings = siblings;
73
+ }
74
+
75
+ if (finalSiblings.length <= 1) return 0;
76
+
77
+ // Sort:
78
+ finalSiblings.sort((a, b) => {
79
+ if (usedAreaFallback) {
80
+ // For horizontal paths (Left/Right), we split them vertically by their overall Y midpoint
81
+ const aSPos = getAbsolutePosition(a.source, nodes);
82
+ const aTPos = getAbsolutePosition(a.target, nodes);
83
+ const bSPos = getAbsolutePosition(b.source, nodes);
84
+ const bTPos = getAbsolutePosition(b.target, nodes);
85
+
86
+ if (isVertical) {
87
+ const midA = (aSPos.x + aTPos.x) / 2;
88
+ const midB = (bSPos.x + bTPos.x) / 2;
89
+ if (midA !== midB) return midA - midB;
90
+ } else {
91
+ const midA = (aSPos.y + aTPos.y) / 2;
92
+ const midB = (bSPos.y + bTPos.y) / 2;
93
+ if (midA !== midB) return midA - midB;
94
+ }
95
+ return a.id.localeCompare(b.id);
96
+ }
97
+ const otherIdA = isSource ? a.target : a.source;
98
+ const otherIdB = isSource ? b.target : b.source;
99
+ const posA = getAbsolutePosition(otherIdA, nodes);
100
+ const posB = getAbsolutePosition(otherIdB, nodes);
101
+ const primary = isVertical ? (posA.x - posB.x) : (posA.y - posB.y);
102
+ if (primary !== 0) return primary;
103
+ return a.id.localeCompare(b.id);
104
+ });
105
+
106
+ const index = finalSiblings.findIndex(e => e.id === edgeId);
107
+ return (index - (finalSiblings.length - 1) / 2) * OFFSET_STEP;
108
+ }
109
+
110
+ const ELK_OPTIONS: Record<string, string> = {
111
+ 'elk.algorithm': 'layered',
112
+ 'elk.direction': 'RIGHT',
113
+ //Horizontal
114
+ 'elk.spacing.nodeNodeBetweenLayers': '80',
115
+ //Vertical
116
+ 'elk.spacing.nodeNode': '80',
117
+
118
+ 'elk.spacing.edgeNode': '60', // Gap between lines and boxes
119
+ 'elk.spacing.edgeEdge': '40', // Gap between parallel lines
120
+
121
+ 'elk.layered.spacing.edgeNodeBetweenLayers': '60',
122
+ 'elk.edgeRouting': 'ORTHOGONAL',
123
+ 'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
124
+ 'elk.portConstraints': 'FREE',
125
+ 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
126
+ 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
127
+ };
128
+
129
+ function convertEdgesToElkEdges(input: FlowGraphInput) {
130
+ return input.edges.map((edge) => ({
131
+ id: edge.id,
132
+ sources: [edge.source],
133
+ targets: [edge.target],
134
+ originalEdge: edge,
135
+ }));
136
+ }
137
+
138
+ /**
139
+ * Helper to get the dimensions of a node, prioritizing measured values from Svelte Flow.
140
+ */
141
+ export function getNodeDimensions(node: Node<FlowNodeData>): { w: number, h: number } {
142
+ const w = Math.round(node.measured?.width ?? node.width ?? 150);
143
+ const h = Math.round(node.measured?.height ?? node.height ?? 40);
144
+ return {w, h};
145
+ }
146
+
147
+
148
+ export async function layoutFlowGraph(input: FlowGraphInput): Promise<FlowGraphOutput> {
149
+ const elkEdges = convertEdgesToElkEdges(input);
150
+ // console.debug('[layoutFlowGraph] Starting layout with input:', {input});
151
+ // Helper: Build node properties and handle dimensions
152
+ const prepareElkNode = (node: Node<FlowNodeData>) => {
153
+ const {w, h} = getNodeDimensions(node);
154
+ return {
155
+ ...node,
156
+ width: w,
157
+ height: h
158
+ };
159
+ };
160
+
161
+ // 1. Build the nested ELK structure
162
+ const elkChildren: ElkNode[] = input.groupNodes.map(parent => ({
163
+ ...prepareElkNode(parent),
164
+ layoutOptions: {
165
+ ...ELK_OPTIONS,
166
+ 'elk.padding': `[top=60,left=${PADDING},bottom=${PADDING},right=${PADDING}]`,
167
+ },
168
+ children: input.serviceNodes.filter(child => child.parentId === parent.id).map(prepareElkNode)
169
+ }));
170
+
171
+ // Add nodes that aren't in any group
172
+ input.serviceNodes
173
+ .filter(child => child.parentId === undefined)
174
+ .forEach(node => elkChildren.push(prepareElkNode(node)));
175
+
176
+ const elkGraph = {
177
+ id: 'root',
178
+ layoutOptions: ELK_OPTIONS,
179
+ children: elkChildren,
180
+ edges: elkEdges
181
+ };
182
+
183
+ return elk.layout(elkGraph).then(async (layoutedGraph) => {
184
+ console.debug('[layoutFlowGraph] Layout completed:', {layoutedGraph});
185
+ const flattenedNodes: Node<FlowNodeData>[] = [];
186
+ const flattenedEdges: Edge<FlowEdgeData>[] = [];
187
+
188
+ // Lookup maps to handle the coordinate system bridge
189
+ const parentPosLookup = new Map<string, { x: number, y: number }>();
190
+ const nodeToParent = new Map<string, string>();
191
+ const nodeLookup = new Map<String, Node<FlowNodeData>>();
192
+
193
+ // 2. Process the results
194
+ layoutedGraph.children?.forEach(parentOrOrphan => {
195
+ const px = parentOrOrphan.x || 0;
196
+ const py = parentOrOrphan.y || 0;
197
+
198
+ // If it has children, it's a group node in our context
199
+ if (parentOrOrphan.children) {
200
+ parentPosLookup.set(parentOrOrphan.id, {x: px, y: py});
201
+
202
+ flattenedNodes.push({
203
+ ...parentOrOrphan,
204
+ position: {x: px, y: py},
205
+ draggable: true,
206
+ } as Node<FlowNodeData>);
207
+
208
+ // Map children and record their parent for edge offsetting
209
+ parentOrOrphan.children.forEach(child => {
210
+ nodeToParent.set(child.id, parentOrOrphan.id);
211
+ const node = {
212
+ ...child,
213
+ position: {x: child.x || 0, y: child.y || 0},
214
+ draggable: true,
215
+ extent: [[PADDING, 50], [(parentOrOrphan.width || 0) - PADDING, (parentOrOrphan.height || 0) - PADDING]]
216
+ } as Node<FlowNodeData>;
217
+ flattenedNodes.push(node);
218
+ nodeLookup.set(child.id, node);
219
+ });
220
+ } else {
221
+ // It's a top-level orphan node
222
+ const node = {
223
+ ...parentOrOrphan,
224
+ position: {x: px, y: py},
225
+ draggable: true,
226
+ } as Node<FlowNodeData>;
227
+ flattenedNodes.push(node);
228
+ nodeLookup.set(node.id, node);
229
+ }
230
+ });
231
+
232
+ const edges = input.edges.filter(e => e.data.connectionType !== 'service-group');
233
+
234
+ return {
235
+ nodes: flattenedNodes,
236
+ edges,
237
+ signature: input.signature
238
+ };
239
+ });
240
+ }
@@ -0,0 +1,5 @@
1
+ import type { ServiceDefinition } from '$shared/types'
2
+
3
+ //TODO: Do you need to exist, or can i put you somewhere else?
4
+ export const getServiceNodeIdFromDefinition = (service: ServiceDefinition) =>
5
+ `svc::${service.groupId}::${service.identifier}`
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { buildGroupServicesGraph } from './servicesGraph'
3
+ import { ConnectionType, ServiceType, type GroupInfo, type ServiceDefinition } from '$shared/types'
4
+
5
+ describe('servicesGraph', () => {
6
+ const currentGroup: GroupInfo = {
7
+ id: 'g1',
8
+ name: 'Group 1',
9
+ groupName: 'g1'
10
+ }
11
+
12
+ const allGroups = new Map<string, GroupInfo>([
13
+ ['g1', currentGroup]
14
+ ])
15
+
16
+ it('correctly loads bidirectional connections between two services in the same group', () => {
17
+ const serviceA: ServiceDefinition = {
18
+ friendlyName: 'Service A',
19
+ identifier: 'svc-a',
20
+ serviceType: ServiceType.LAMBDA,
21
+ groupId: 'g1',
22
+ outgoingConnections: [
23
+ {
24
+ connectionType: ConnectionType.TCP,
25
+ targetIdentifier: { groupId: 'g1', serviceId: 'svc-b' },
26
+ description: 'A to B'
27
+ }
28
+ ]
29
+ }
30
+
31
+ const serviceB: ServiceDefinition = {
32
+ friendlyName: 'Service B',
33
+ identifier: 'svc-b',
34
+ serviceType: ServiceType.DYNAMODB,
35
+ groupId: 'g1',
36
+ outgoingConnections: [
37
+ {
38
+ connectionType: ConnectionType.TCP,
39
+ targetIdentifier: { groupId: 'g1', serviceId: 'svc-a' },
40
+ description: 'B to A'
41
+ }
42
+ ]
43
+ }
44
+
45
+ const currentServices = [serviceA, serviceB]
46
+ const result = buildGroupServicesGraph(currentGroup, currentServices, allGroups, [])
47
+
48
+ // We expect two nodes and two edges
49
+ expect(result.graph.serviceNodes.length).toBe(2)
50
+
51
+ // Check edges
52
+ expect(result.graph.edges.length).toBe(2)
53
+
54
+ const edgeAB = result.graph.edges.find(e => e.source === 'svc::g1::svc-a' && e.target === 'svc::g1::svc-b')
55
+ const edgeBA = result.graph.edges.find(e => e.source === 'svc::g1::svc-b' && e.target === 'svc::g1::svc-a')
56
+
57
+ expect(edgeAB).toBeDefined()
58
+ expect(edgeBA).toBeDefined()
59
+
60
+ // Verify IDs are unique
61
+ expect(edgeAB?.id).not.toBe(edgeBA?.id)
62
+ })
63
+
64
+ it('correctly loads bidirectional connections involving an external service', () => {
65
+ const serviceA: ServiceDefinition = {
66
+ friendlyName: 'Service A',
67
+ identifier: 'svc-a',
68
+ serviceType: ServiceType.LAMBDA,
69
+ groupId: 'g1',
70
+ outgoingConnections: [
71
+ {
72
+ connectionType: ConnectionType.TCP,
73
+ targetIdentifier: { groupId: 'g2', serviceId: 'svc-ext' },
74
+ description: 'A to External'
75
+ }
76
+ ],
77
+ incomingConnections: [
78
+ {
79
+ connectionType: ConnectionType.TCP,
80
+ sourceIdentifier: { groupId: 'g2', serviceId: 'svc-ext' },
81
+ description: 'External to A'
82
+ }
83
+ ]
84
+ }
85
+
86
+ const group2: GroupInfo = { id: 'g2', name: 'Group 2', groupName: 'g2' }
87
+ const serviceExt: ServiceDefinition = {
88
+ friendlyName: 'External Service',
89
+ identifier: 'svc-ext',
90
+ serviceType: ServiceType.RDS,
91
+ groupId: 'g2'
92
+ }
93
+
94
+ const externalGroups = [
95
+ {
96
+ group: group2,
97
+ services: [serviceExt],
98
+ direction: 'outgoing' as const
99
+ },
100
+ {
101
+ group: group2,
102
+ services: [serviceExt],
103
+ direction: 'incoming' as const
104
+ }
105
+ ]
106
+
107
+ const result = buildGroupServicesGraph(currentGroup, [serviceA], allGroups, [], externalGroups)
108
+
109
+ // Should have serviceA and ONE external node (shared between directions)
110
+ expect(result.graph.serviceNodes.length).toBe(2)
111
+ expect(result.graph.edges.length).toBe(2)
112
+
113
+ const edgeToExt = result.graph.edges.find(e => e.source === 'svc::g1::svc-a' && e.target === 'svc::g2::svc-ext')
114
+ const edgeFromExt = result.graph.edges.find(e => e.target === 'svc::g1::svc-a' && e.source === 'svc::g2::svc-ext')
115
+
116
+ expect(edgeToExt).toBeDefined()
117
+ expect(edgeFromExt).toBeDefined()
118
+ })
119
+ })