@kiberon-labs/behave-graph-flow 2.0.0 → 3.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 (251) hide show
  1. package/.storybook/manager.ts +6 -0
  2. package/.storybook/preview.ts +49 -1
  3. package/.storybook/styles.css +9 -3
  4. package/.turbo/turbo-build.log +1 -1
  5. package/CHANGELOG.md +368 -0
  6. package/dist/AnyControlImpl-Ds-CShIB.js +20 -0
  7. package/dist/AnyControlImpl-Ds-CShIB.js.map +1 -0
  8. package/dist/DocumentationBrowserPanelImpl-deZNzFX8.js +166 -0
  9. package/dist/DocumentationBrowserPanelImpl-deZNzFX8.js.map +1 -0
  10. package/dist/index.css +36 -33
  11. package/dist/index.css.map +1 -1
  12. package/dist/index.d.ts +1865 -550
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +14357 -11221
  15. package/dist/index.js.map +1 -1
  16. package/dist/noteImpl-KkrrWgJd.js +242 -0
  17. package/dist/noteImpl-KkrrWgJd.js.map +1 -0
  18. package/dist/styles.module-CvmpDkZj.css +3 -0
  19. package/dist/styles.module-CvmpDkZj.css.map +1 -0
  20. package/dist/styles.module-DZxg8aW9.js +271 -0
  21. package/dist/styles.module-DZxg8aW9.js.map +1 -0
  22. package/dist/useChangeNodeData-ChQGK7AI.js +23 -0
  23. package/dist/useChangeNodeData-ChQGK7AI.js.map +1 -0
  24. package/docs/protocol.md +43 -20
  25. package/package.json +5 -9
  26. package/src/components/FloatingToolbar/index.module.css +5 -13
  27. package/src/components/FloatingToolbar/index.tsx +9 -9
  28. package/src/components/Flow.tsx +34 -23
  29. package/src/components/contextMenus/DynamicContextMenu.tsx +85 -0
  30. package/src/components/contextMenus/NodePicker.module.css +13 -13
  31. package/src/components/contextMenus/edge.tsx +9 -95
  32. package/src/components/contextMenus/node.tsx +9 -149
  33. package/src/components/contextMenus/selection.tsx +5 -71
  34. package/src/components/controls/any/AnyControlImpl.tsx +14 -0
  35. package/src/components/controls/any/index.tsx +13 -2
  36. package/src/components/edges/index.tsx +75 -69
  37. package/src/components/layoutController/index.module.css +3 -0
  38. package/src/components/layoutController/index.tsx +24 -1
  39. package/src/components/layoutController/utils.ts +46 -3
  40. package/src/components/menubar/defaults.tsx +55 -19
  41. package/src/components/menubar/menuItem.module.css +18 -3
  42. package/src/components/menubar/menuItem.tsx +34 -1
  43. package/src/components/nodes/behave/NodeContainer.module.css +26 -25
  44. package/src/components/nodes/group/index.tsx +3 -3
  45. package/src/components/nodes/wrapper/styles.module.css +6 -32
  46. package/src/components/panels/alignment/index.module.css +0 -10
  47. package/src/components/panels/alignment/index.tsx +4 -4
  48. package/src/components/panels/base/styles.module.css +2 -2
  49. package/src/components/panels/common/PanelHeader.module.css +24 -0
  50. package/src/components/panels/common/PanelHeader.tsx +22 -0
  51. package/src/components/panels/common/SectionTitle.module.css +13 -0
  52. package/src/components/panels/common/SectionTitle.tsx +10 -0
  53. package/src/components/panels/events/EditEventPanel.tsx +14 -5
  54. package/src/components/panels/events/ManageEventsPanel.tsx +11 -8
  55. package/src/components/panels/events/styles.module.css +6 -64
  56. package/src/components/panels/graphProperties/index.tsx +125 -0
  57. package/src/components/panels/history/index.tsx +2 -2
  58. package/src/components/panels/history/styles.module.css +0 -9
  59. package/src/components/panels/keymaps/index.module.css +3 -13
  60. package/src/components/panels/keymaps/index.tsx +1 -2
  61. package/src/components/panels/layers/index.tsx +20 -15
  62. package/src/components/panels/layers/styles.module.css +9 -12
  63. package/src/components/panels/legend/index.tsx +1 -1
  64. package/src/components/panels/logs/index.module.css +25 -19
  65. package/src/components/panels/logs/index.tsx +7 -7
  66. package/src/components/panels/nodeInputs/InputsGroup.tsx +1 -0
  67. package/src/components/panels/nodeInputs/NodeSettings.tsx +2 -2
  68. package/src/components/panels/nodeInputs/NodeTitleEditor.tsx +1 -1
  69. package/src/components/panels/nodeInputs/OutputsGroup.tsx +2 -12
  70. package/src/components/panels/nodeInputs/index.module.css +99 -75
  71. package/src/components/panels/nodeInputs/index.tsx +21 -11
  72. package/src/components/panels/nodeInputs/useNodeHandlers.ts +2 -2
  73. package/src/components/panels/nodeInputs/useNodeInputsData.ts +23 -43
  74. package/src/components/panels/nodePicker/index.tsx +8 -8
  75. package/src/components/panels/panel/index.module.css +7 -7
  76. package/src/components/panels/search/index.module.css +0 -50
  77. package/src/components/panels/search/index.tsx +2 -2
  78. package/src/components/panels/systemSettings/ConversionsSettings.tsx +203 -0
  79. package/src/components/panels/systemSettings/index.tsx +221 -176
  80. package/src/components/panels/systemSettings/styles.module.css +135 -8
  81. package/src/components/panels/traces/GridLines.tsx +1 -1
  82. package/src/components/panels/traces/TimeGrid.tsx +3 -3
  83. package/src/components/panels/traces/TraceLane.tsx +1 -1
  84. package/src/components/panels/traces/index.module.css +1 -8
  85. package/src/components/panels/traces/index.tsx +8 -4
  86. package/src/components/panels/traces/useDerivedSpans.ts +241 -146
  87. package/src/components/panels/traces/utils.ts +8 -0
  88. package/src/components/panels/variables/CreateVariableScreen.tsx +3 -3
  89. package/src/components/panels/variables/ManageVariablesScreen.tsx +12 -9
  90. package/src/components/panels/variables/index.tsx +2 -2
  91. package/src/components/panels/variables/styles.module.css +4 -91
  92. package/src/components/primitives/icon.module.css +4 -4
  93. package/src/components/sockets/input/index.tsx +9 -2
  94. package/src/components/sockets/input/styles.module.css +2 -3
  95. package/src/components/sockets/output/index.tsx +10 -3
  96. package/src/components/sockets/output/styles.module.css +1 -6
  97. package/src/css/notes.css +135 -0
  98. package/src/css/prosemirror.css +3 -3
  99. package/src/css/rc-dock.css +143 -43
  100. package/src/css/rc-menu.css +56 -55
  101. package/src/css/themes/kiberon.css +127 -0
  102. package/src/css/vars.css +197 -13
  103. package/src/css/vscode-elements.css +124 -0
  104. package/src/generators/CallSubgraphGenerator.tsx +136 -0
  105. package/src/generators/CustomEventOnTriggeredGenerator.tsx +2 -2
  106. package/src/generators/GraphBoundaryGenerator.module.css +32 -0
  107. package/src/generators/GraphBoundaryGenerator.tsx +193 -0
  108. package/src/generators/SequenceGenerator.tsx +2 -2
  109. package/src/generators/SwitchOnIntegerGenerator.tsx +2 -2
  110. package/src/generators/SwitchOnStringGenerator.tsx +2 -2
  111. package/src/generators/callSubgraphSync.ts +126 -0
  112. package/src/generators/registerDefaultGenerators.ts +21 -0
  113. package/src/generators/registerDefaults.ts +26 -0
  114. package/src/hooks/useBehaveGraphFlow.ts +2 -2
  115. package/src/hooks/useFlowHandlers.ts +47 -9
  116. package/src/hooks/useWasdPan.ts +26 -4
  117. package/src/index.css +4 -16
  118. package/src/index.ts +17 -0
  119. package/src/manifest/contributionRegistry.ts +93 -0
  120. package/src/manifest/index.ts +4 -0
  121. package/src/manifest/loadManifest.ts +82 -0
  122. package/src/manifest/manifestPlugin.ts +29 -0
  123. package/src/manifest/passthroughValueType.ts +40 -0
  124. package/src/plugin/alignment/index.ts +22 -12
  125. package/src/plugin/autosave/controller.ts +366 -0
  126. package/src/plugin/autosave/index.tsx +114 -0
  127. package/src/plugin/autosave/panel/BackupPanel.tsx +141 -0
  128. package/src/plugin/autosave/panel/index.tsx +1 -0
  129. package/src/plugin/autosave/panel/styles.module.css +56 -0
  130. package/src/plugin/autosave/settings.ts +65 -0
  131. package/src/plugin/autosave/storage.ts +147 -0
  132. package/src/plugin/docs/index.tsx +2 -4
  133. package/src/plugin/docs/panel/DocumentationBrowserPanelImpl.tsx +200 -0
  134. package/src/plugin/docs/panel/index.tsx +15 -194
  135. package/src/plugin/docs/panel/styles.module.css +8 -8
  136. package/src/plugin/graphrunner/actions.ts +258 -185
  137. package/src/plugin/graphrunner/buttons.tsx +34 -26
  138. package/src/plugin/graphrunner/client.ts +4 -1
  139. package/src/plugin/graphrunner/index.tsx +29 -100
  140. package/src/plugin/graphrunner/panel.tsx +2 -2
  141. package/src/plugin/graphrunner/runController.ts +283 -0
  142. package/src/plugin/graphrunner/runner.ts +21 -192
  143. package/src/plugin/graphrunner/store.ts +14 -24
  144. package/src/plugin/graphrunner/styles.module.css +17 -57
  145. package/src/plugin/graphrunner/transport.ts +26 -0
  146. package/src/plugin/graphrunner/types.ts +21 -0
  147. package/src/plugin/graphrunner-local/execution-utils.ts +260 -80
  148. package/src/plugin/graphrunner-local/index.tsx +8 -2
  149. package/src/plugin/graphrunner-local/panel.tsx +131 -175
  150. package/src/plugin/graphrunner-local/styles.module.css +57 -76
  151. package/src/plugin/graphrunner-local/transport.ts +151 -184
  152. package/src/plugin/graphrunner-webworker/graph-executor.worker.ts +2 -0
  153. package/src/plugin/graphrunner-webworker/index.tsx +4 -10
  154. package/src/plugin/graphrunner-webworker/store.ts +9 -0
  155. package/src/plugin/kitchen-sink/index.ts +38 -0
  156. package/src/{layout/dagre.tsx → plugin/layout/dagre.ts} +17 -5
  157. package/src/{layout → plugin/layout}/elk.ts +22 -6
  158. package/src/plugin/layout/index.ts +80 -0
  159. package/src/plugin/notes/FormatToolbar.tsx +200 -0
  160. package/src/plugin/notes/index.tsx +191 -0
  161. package/src/plugin/notes/nodeActions.ts +100 -0
  162. package/src/plugin/notes/note.tsx +20 -0
  163. package/src/plugin/notes/noteImpl.tsx +89 -0
  164. package/src/plugin/realtime/realtimeRunner.ts +58 -4
  165. package/src/specifics/CustomEventOnTriggeredSpecific.tsx +2 -2
  166. package/src/specifics/CustomEventTriggerSpecific.tsx +2 -2
  167. package/src/specifics/VariableGetSpecific.tsx +2 -2
  168. package/src/specifics/VariableSetSpecific.tsx +2 -2
  169. package/src/store/actions.tsx +5 -5
  170. package/src/store/commands.ts +278 -0
  171. package/src/store/contextMenu.ts +192 -0
  172. package/src/store/conversions.ts +47 -0
  173. package/src/store/flow.tsx +23 -38
  174. package/src/store/graphMeta.ts +39 -0
  175. package/src/store/hotKeys.tsx +301 -260
  176. package/src/store/layers.ts +3 -3
  177. package/src/store/registry.ts +12 -4
  178. package/src/store/selection.ts +3 -3
  179. package/src/store/settings.ts +82 -82
  180. package/src/store/settingsSchema.ts +210 -0
  181. package/src/store/tabs.ts +5 -1
  182. package/src/store/traces.ts +3 -3
  183. package/src/system/graph.ts +11 -14
  184. package/src/system/graphSession.ts +172 -0
  185. package/src/system/index.ts +3 -0
  186. package/src/system/notifications.ts +13 -0
  187. package/src/system/persistence.ts +82 -0
  188. package/src/system/plugin.ts +28 -0
  189. package/src/system/provider.tsx +64 -0
  190. package/src/system/system.ts +518 -88
  191. package/src/system/tabLoader.tsx +70 -32
  192. package/src/system/undoRedo.ts +1 -1
  193. package/src/transformers/Uigraph.ts +5 -4
  194. package/src/transformers/contract.ts +87 -0
  195. package/src/transformers/flowToBehave.ts +13 -5
  196. package/src/types/nodes.ts +8 -3
  197. package/src/types.ts +2 -0
  198. package/src/util/autoConvert.ts +200 -0
  199. package/src/util/isValidConnection.ts +23 -2
  200. package/stories/defaults/defaultStoryProvider.tsx +17 -14
  201. package/stories/defaults/systemGenerator.ts +6 -1
  202. package/stories/{components/nodes/comment.stories.tsx → plugins/notes.stories.tsx} +24 -30
  203. package/tests/autoConvert.test.ts +329 -0
  204. package/tests/autosavePlugin.test.ts +204 -0
  205. package/tests/callSubgraphSync.test.ts +148 -0
  206. package/tests/commandRegistry.test.ts +137 -0
  207. package/tests/contract.test.ts +51 -0
  208. package/tests/contractSerialize.test.ts +62 -0
  209. package/tests/deriveSpans.test.ts +71 -0
  210. package/tests/flowToBehave.test.ts +2 -1
  211. package/tests/hotkeys.test.ts +79 -0
  212. package/tests/keepAliveLifecycle.test.ts +167 -0
  213. package/tests/loadManifest.test.ts +113 -0
  214. package/tests/noteMarkdown.test.ts +65 -0
  215. package/tests/notesPlugin.test.ts +162 -0
  216. package/tests/persistence.test.ts +51 -0
  217. package/tests/saveLoad.test.ts +7 -6
  218. package/tests/settings.test.ts +178 -0
  219. package/tests/traceStore.test.ts +46 -0
  220. package/tests/visual/README.md +2 -2
  221. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-conversation-chromium-win32.png +0 -0
  222. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-events-chromium-win32.png +0 -0
  223. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-history-chromium-win32.png +0 -0
  224. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-keymaps-chromium-win32.png +0 -0
  225. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-layers-chromium-win32.png +0 -0
  226. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-legend-chromium-win32.png +0 -0
  227. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-localGraphRunner-chromium-win32.png +0 -0
  228. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-logs-chromium-win32.png +0 -0
  229. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-nodeInputs-chromium-win32.png +0 -0
  230. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-nodePicker-chromium-win32.png +0 -0
  231. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-panel-chromium-win32.png +0 -0
  232. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-search-chromium-win32.png +0 -0
  233. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-systemSettings-chromium-win32.png +0 -0
  234. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-variables-chromium-win32.png +0 -0
  235. package/tests/visual/panels.visual.test.tsx +3 -3
  236. package/tests/wasdPan.test.ts +71 -0
  237. package/vitest.config.ts +1 -1
  238. package/vitest.visual.config.ts +7 -0
  239. package/.storybook/vscode.css +0 -814
  240. package/src/components/nodes/comment/FormatToolbar.tsx +0 -118
  241. package/src/components/nodes/comment/comment.tsx +0 -103
  242. package/src/components/nodes/comment/styles.module.css +0 -150
  243. package/src/components/panels/conversation/index.module.css +0 -151
  244. package/src/components/panels/conversation/index.tsx +0 -162
  245. package/src/components/panels/events/CustomEventsEditor.tsx +0 -384
  246. package/src/css/vscode.css +0 -13
  247. package/src/hooks/useDetachNodes.ts +0 -39
  248. package/src/plugin/graphrunner-webworker/types.ts +0 -17
  249. package/src/specifics/registerDefaultSpecifics.ts +0 -5
  250. package/src/store/chat.ts +0 -73
  251. package/src/store/graphRunnerClient.ts +0 -110
@@ -8,54 +8,38 @@ import {
8
8
  useNodesState
9
9
  } from 'reactflow';
10
10
  import { useMemo } from 'react';
11
- import { useStore } from 'zustand';
12
11
 
13
12
  import { DefaultSystemProvider } from '~/defaults/defaultStoryProvider';
14
- import { useSystem } from '@/system/provider';
15
- import { CommentNode } from '@/components/nodes/comment/comment';
13
+ import { NoteNode, NOTE_NODE_TYPE } from '@/plugin/notes';
16
14
 
17
- function Canvas({ selectedId }: { selectedId?: string }) {
18
- const sys = useSystem();
19
- const allSpecs = useStore(sys.specStore, (s) => s.specs);
15
+ const DEFAULT_TEXT =
16
+ '# Heading\n\nA **markdown** note with `code` and:\n\n- a list\n- of items\n\n> and a quote';
20
17
 
21
- const specDict = useMemo(() => {
22
- const dict: Record<string, (typeof allSpecs)[number]> = {};
23
- for (const spec of allSpecs) {
24
- dict[spec.type] = spec;
25
- }
26
- return dict;
27
- }, [allSpecs]);
18
+ const VIDEO_TEXT =
19
+ 'An embedded video:\n\n<div data-youtube-video><iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"></iframe></div>';
28
20
 
21
+ function Canvas({ selectedId, text }: { selectedId?: string; text?: string }) {
29
22
  const nodeTypes = useMemo(() => {
30
23
  return {
31
- comment: CommentNode
24
+ [NOTE_NODE_TYPE]: NoteNode
32
25
  };
33
- }, [allSpecs, specDict]);
26
+ }, []);
34
27
 
35
28
  const [nodes, , onNodesChange] = useNodesState([
36
29
  {
37
30
  id: '0',
38
- type: 'comment',
31
+ type: NOTE_NODE_TYPE,
39
32
  position: { x: 0, y: 0 },
40
33
  selected: selectedId === '0',
34
+ style: { width: 320, height: 260 },
41
35
  data: {
42
36
  annotations: {},
43
- configuration: {},
44
- type: 'lifecycle/onStart',
45
- ports: {}
37
+ text: text ?? DEFAULT_TEXT
46
38
  }
47
39
  }
48
40
  ]);
49
41
 
50
- const [edges, , onEdgesChange] = useEdgesState([
51
- {
52
- id: 'e0-1',
53
- source: '0',
54
- sourceHandle: 'flow',
55
- target: '1',
56
- targetHandle: 'flow'
57
- }
58
- ]);
42
+ const [edges, , onEdgesChange] = useEdgesState([]);
59
43
 
60
44
  return (
61
45
  <div style={{ width: 900, height: 520 }}>
@@ -78,7 +62,7 @@ function Canvas({ selectedId }: { selectedId?: string }) {
78
62
  }
79
63
 
80
64
  const meta: Meta<typeof Canvas> = {
81
- title: 'Components/Nodes/Comment',
65
+ title: 'Plugins/Notes',
82
66
  component: Canvas
83
67
  };
84
68
 
@@ -99,7 +83,17 @@ export const Selected: Story = {
99
83
  render: () => (
100
84
  <DefaultSystemProvider>
101
85
  <ReactFlowProvider>
102
- <Canvas selectedId="1" />
86
+ <Canvas selectedId="0" />
87
+ </ReactFlowProvider>
88
+ </DefaultSystemProvider>
89
+ )
90
+ };
91
+
92
+ export const WithVideo: Story = {
93
+ render: () => (
94
+ <DefaultSystemProvider>
95
+ <ReactFlowProvider>
96
+ <Canvas text={VIDEO_TEXT} />
103
97
  </ReactFlowProvider>
104
98
  </DefaultSystemProvider>
105
99
  )
@@ -0,0 +1,329 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { Node, Connection } from 'reactflow';
3
+ import type { NodeSpecJSON } from '@kiberon-labs/behave-graph';
4
+ import {
5
+ findConverterSpec,
6
+ resolveConverter,
7
+ buildConverterInsertion
8
+ } from '../src/util/autoConvert.js';
9
+
10
+ const specs = [
11
+ {
12
+ type: 'src',
13
+ label: '',
14
+ category: '',
15
+ inputs: [],
16
+ outputs: [{ name: 'out', valueType: 'integer' }]
17
+ },
18
+ {
19
+ type: 'dst',
20
+ label: '',
21
+ category: '',
22
+ inputs: [{ name: 'in', valueType: 'string' }],
23
+ outputs: []
24
+ },
25
+ {
26
+ type: 'math/toString/integer',
27
+ label: 'To String',
28
+ category: 'Logic',
29
+ inputs: [{ name: 'a', valueType: 'integer' }],
30
+ outputs: [{ name: 'result', valueType: 'string' }]
31
+ }
32
+ ] as unknown as NodeSpecJSON[];
33
+
34
+ const node = (id: string, type: string, x: number): Node => ({
35
+ id,
36
+ type: 'behaveNode',
37
+ position: { x, y: 0 },
38
+ data: { type, configuration: {}, ports: {} } as any
39
+ });
40
+
41
+ const conn = (
42
+ source: string,
43
+ sourceHandle: string,
44
+ target: string,
45
+ targetHandle: string
46
+ ): Connection => ({ source, sourceHandle, target, targetHandle });
47
+
48
+ describe('findConverterSpec', () => {
49
+ it('finds a single-in/single-out converter for the type pair', () => {
50
+ expect(findConverterSpec(specs, 'integer', 'string')?.type).toBe(
51
+ 'math/toString/integer'
52
+ );
53
+ });
54
+
55
+ it('ignores nodes that have flow sockets', () => {
56
+ const flowNode = [
57
+ {
58
+ type: 'fn',
59
+ inputs: [
60
+ { name: 'flow', valueType: 'flow' },
61
+ { name: 'a', valueType: 'integer' }
62
+ ],
63
+ outputs: [{ name: 'result', valueType: 'string' }]
64
+ }
65
+ ] as unknown as NodeSpecJSON[];
66
+ expect(findConverterSpec(flowNode, 'integer', 'string')).toBeUndefined();
67
+ });
68
+
69
+ it('returns undefined when no converter exists', () => {
70
+ expect(findConverterSpec(specs, 'string', 'boolean')).toBeUndefined();
71
+ });
72
+ });
73
+
74
+ describe('resolveConverter (configurable rules)', () => {
75
+ const customSpecs = [
76
+ ...specs,
77
+ {
78
+ type: 'custom/intToStr',
79
+ inputs: [{ name: 'x', valueType: 'integer' }],
80
+ outputs: [{ name: 'y', valueType: 'string' }]
81
+ }
82
+ ] as unknown as NodeSpecJSON[];
83
+
84
+ it('prefers a registered custom rule over the spec heuristic', () => {
85
+ const rules = [
86
+ { from: 'integer', to: 'string', nodeType: 'custom/intToStr' }
87
+ ];
88
+ expect(resolveConverter(customSpecs, 'integer', 'string', rules)).toEqual({
89
+ nodeType: 'custom/intToStr',
90
+ inputName: 'x',
91
+ outputName: 'y'
92
+ });
93
+ });
94
+
95
+ it('falls back to the spec heuristic when no rule matches', () => {
96
+ expect(resolveConverter(specs, 'integer', 'string', [])?.nodeType).toBe(
97
+ 'math/toString/integer'
98
+ );
99
+ });
100
+
101
+ it('honours explicit inputKey/outputKey from the rule', () => {
102
+ const rules = [
103
+ {
104
+ from: 'integer',
105
+ to: 'string',
106
+ nodeType: 'whatever',
107
+ inputKey: 'in',
108
+ outputKey: 'out'
109
+ }
110
+ ];
111
+ expect(resolveConverter([], 'integer', 'string', rules)).toEqual({
112
+ nodeType: 'whatever',
113
+ inputName: 'in',
114
+ outputName: 'out'
115
+ });
116
+ });
117
+
118
+ // A converter node with several inputs/outputs (e.g. value + config ports).
119
+ const multiPortSpecs = [
120
+ {
121
+ type: 'multi',
122
+ inputs: [
123
+ { name: 'flow', valueType: 'flow' },
124
+ { name: 'label', valueType: 'string' }, // first non-flow, wrong type
125
+ { name: 'n', valueType: 'integer' } // the actual source port
126
+ ],
127
+ outputs: [
128
+ { name: 'flag', valueType: 'boolean' }, // first non-flow, wrong type
129
+ { name: 'text', valueType: 'string' } // the actual target port
130
+ ]
131
+ }
132
+ ] as unknown as NodeSpecJSON[];
133
+
134
+ it('resolves a multi-port converter by type when keys are omitted', () => {
135
+ const rules = [{ from: 'integer', to: 'string', nodeType: 'multi' }];
136
+ expect(
137
+ resolveConverter(multiPortSpecs, 'integer', 'string', rules)
138
+ ).toEqual({ nodeType: 'multi', inputName: 'n', outputName: 'text' });
139
+ });
140
+
141
+ it('honours explicit keys to pick a specific port among several of a type', () => {
142
+ const twoEachSpecs = [
143
+ {
144
+ type: 'multi2',
145
+ inputs: [
146
+ { name: 'a', valueType: 'integer' },
147
+ { name: 'b', valueType: 'integer' }
148
+ ],
149
+ outputs: [
150
+ { name: 'x', valueType: 'string' },
151
+ { name: 'y', valueType: 'string' }
152
+ ]
153
+ }
154
+ ] as unknown as NodeSpecJSON[];
155
+ const rules = [
156
+ {
157
+ from: 'integer',
158
+ to: 'string',
159
+ nodeType: 'multi2',
160
+ inputKey: 'b',
161
+ outputKey: 'y'
162
+ }
163
+ ];
164
+ expect(resolveConverter(twoEachSpecs, 'integer', 'string', rules)).toEqual({
165
+ nodeType: 'multi2',
166
+ inputName: 'b',
167
+ outputName: 'y'
168
+ });
169
+ });
170
+ });
171
+
172
+ describe('buildConverterInsertion', () => {
173
+ const nodes = [node('s', 'src', 0), node('t', 'dst', 100)];
174
+
175
+ it('uses a custom conversion rule when provided', () => {
176
+ const customSpecs = [
177
+ ...specs,
178
+ {
179
+ type: 'custom/intToStr',
180
+ inputs: [{ name: 'x', valueType: 'integer' }],
181
+ outputs: [{ name: 'y', valueType: 'string' }]
182
+ }
183
+ ] as unknown as NodeSpecJSON[];
184
+ const insertion = buildConverterInsertion(
185
+ conn('s', 'out', 't', 'in'),
186
+ nodes,
187
+ customSpecs,
188
+ [{ from: 'integer', to: 'string', nodeType: 'custom/intToStr' }]
189
+ );
190
+ expect(insertion!.node.data.type).toBe('custom/intToStr');
191
+ expect(insertion!.edges[0]!.targetHandle).toBe('x');
192
+ expect(insertion!.edges[1]!.sourceHandle).toBe('y');
193
+ });
194
+
195
+ it('splices a converter between mismatched-but-convertible sockets', () => {
196
+ const insertion = buildConverterInsertion(
197
+ conn('s', 'out', 't', 'in'),
198
+ nodes,
199
+ specs
200
+ );
201
+ expect(insertion).not.toBeNull();
202
+ expect(insertion!.node.data.type).toBe('math/toString/integer');
203
+ expect(insertion!.node.position).toEqual({ x: 50, y: 0 });
204
+ expect(insertion!.edges).toHaveLength(2);
205
+ expect(insertion!.edges[0]).toMatchObject({
206
+ source: 's',
207
+ sourceHandle: 'out',
208
+ target: insertion!.node.id,
209
+ targetHandle: 'a'
210
+ });
211
+ expect(insertion!.edges[1]).toMatchObject({
212
+ source: insertion!.node.id,
213
+ sourceHandle: 'result',
214
+ target: 't',
215
+ targetHandle: 'in'
216
+ });
217
+ });
218
+
219
+ it('wires the type-matched output port of a multi-output converter', () => {
220
+ // Converter emits both a boolean and a string; from→to is integer→string,
221
+ // so the splice must use the `string` output (`text`), not the first output.
222
+ const multiOutSpecs = [
223
+ {
224
+ type: 'src',
225
+ inputs: [],
226
+ outputs: [{ name: 'out', valueType: 'integer' }]
227
+ },
228
+ {
229
+ type: 'dst',
230
+ inputs: [{ name: 'in', valueType: 'string' }],
231
+ outputs: []
232
+ },
233
+ {
234
+ type: 'multiOut',
235
+ inputs: [{ name: 'a', valueType: 'integer' }],
236
+ outputs: [
237
+ { name: 'flag', valueType: 'boolean' },
238
+ { name: 'text', valueType: 'string' }
239
+ ]
240
+ }
241
+ ] as unknown as NodeSpecJSON[];
242
+ const insertion = buildConverterInsertion(
243
+ conn('s', 'out', 't', 'in'),
244
+ nodes,
245
+ multiOutSpecs,
246
+ [{ from: 'integer', to: 'string', nodeType: 'multiOut' }]
247
+ );
248
+ expect(insertion!.node.data.type).toBe('multiOut');
249
+ expect(insertion!.edges[0]!.targetHandle).toBe('a');
250
+ // The converter→target edge leaves the string output, not the boolean one.
251
+ expect(insertion!.edges[1]!.sourceHandle).toBe('text');
252
+ });
253
+
254
+ it('honours an explicit outputKey to pin a specific output port', () => {
255
+ const twoStrOutSpecs = [
256
+ {
257
+ type: 'src',
258
+ inputs: [],
259
+ outputs: [{ name: 'out', valueType: 'integer' }]
260
+ },
261
+ {
262
+ type: 'dst',
263
+ inputs: [{ name: 'in', valueType: 'string' }],
264
+ outputs: []
265
+ },
266
+ {
267
+ type: 'twoOut',
268
+ inputs: [{ name: 'a', valueType: 'integer' }],
269
+ outputs: [
270
+ { name: 'x', valueType: 'string' },
271
+ { name: 'y', valueType: 'string' }
272
+ ]
273
+ }
274
+ ] as unknown as NodeSpecJSON[];
275
+ const insertion = buildConverterInsertion(
276
+ conn('s', 'out', 't', 'in'),
277
+ nodes,
278
+ twoStrOutSpecs,
279
+ [
280
+ {
281
+ from: 'integer',
282
+ to: 'string',
283
+ nodeType: 'twoOut',
284
+ inputKey: 'a',
285
+ outputKey: 'y'
286
+ }
287
+ ]
288
+ );
289
+ expect(insertion!.edges[1]!.sourceHandle).toBe('y');
290
+ });
291
+
292
+ it('returns null for same-type connections', () => {
293
+ const sameSpecs = [
294
+ {
295
+ type: 'src',
296
+ inputs: [],
297
+ outputs: [{ name: 'out', valueType: 'integer' }]
298
+ },
299
+ {
300
+ type: 'dstI',
301
+ inputs: [{ name: 'in', valueType: 'integer' }],
302
+ outputs: []
303
+ }
304
+ ] as unknown as NodeSpecJSON[];
305
+ const ns = [node('s', 'src', 0), node('t', 'dstI', 100)];
306
+ expect(
307
+ buildConverterInsertion(conn('s', 'out', 't', 'in'), ns, sameSpecs)
308
+ ).toBeNull();
309
+ });
310
+
311
+ it('returns null when no converter is available', () => {
312
+ const noConv = [
313
+ {
314
+ type: 'src',
315
+ inputs: [],
316
+ outputs: [{ name: 'out', valueType: 'integer' }]
317
+ },
318
+ {
319
+ type: 'dstB',
320
+ inputs: [{ name: 'in', valueType: 'boolean' }],
321
+ outputs: []
322
+ }
323
+ ] as unknown as NodeSpecJSON[];
324
+ const ns = [node('s', 'src', 0), node('t', 'dstB', 100)];
325
+ expect(
326
+ buildConverterInsertion(conn('s', 'out', 't', 'in'), ns, noConv)
327
+ ).toBeNull();
328
+ });
329
+ });
@@ -0,0 +1,204 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import {
3
+ registerCoreProfile,
4
+ writeNodeSpecsToJSON
5
+ } from '@kiberon-labs/behave-graph';
6
+ import { System } from '../src/system/system.js';
7
+ import type { GraphSession } from '../src/system/graphSession.js';
8
+ import {
9
+ autosavePlugin,
10
+ BackupStorage,
11
+ AUTOSAVE_ENABLED,
12
+ AUTOSAVE_INTERVAL_SECONDS,
13
+ AUTOSAVE_MAX_COPIES
14
+ } from '../src/plugin/autosave/index.js';
15
+ import type { BackupController } from '../src/plugin/autosave/index.js';
16
+
17
+ /** In-memory get/set storage so backups never touch real localStorage. */
18
+ const makeStorage = () => {
19
+ const backing: Record<string, string> = {};
20
+ return {
21
+ backing,
22
+ adapter: {
23
+ getItem: (k: string) => backing[k] ?? null,
24
+ setItem: (k: string, v: string) => {
25
+ backing[k] = v;
26
+ }
27
+ }
28
+ };
29
+ };
30
+
31
+ const NODE = (id: string, x = 0, y = 0) => ({
32
+ id,
33
+ type: 'behaveNode',
34
+ position: { x, y },
35
+ data: {
36
+ type: 'lifecycle/onStart',
37
+ configuration: {},
38
+ ports: {},
39
+ annotations: {}
40
+ }
41
+ });
42
+
43
+ describe('autosave plugin', () => {
44
+ let system: System;
45
+ let session: GraphSession;
46
+ let controller: BackupController;
47
+ let storage: ReturnType<typeof makeStorage>;
48
+
49
+ beforeEach(async () => {
50
+ vi.useFakeTimers();
51
+
52
+ const coreRegistry = registerCoreProfile({
53
+ nodes: {},
54
+ values: {},
55
+ dependencies: {}
56
+ });
57
+ const registry = {
58
+ values: coreRegistry.values,
59
+ specs: writeNodeSpecsToJSON(coreRegistry)
60
+ };
61
+
62
+ storage = makeStorage();
63
+ system = new System(registry);
64
+ await system.registerPlugin(autosavePlugin, {
65
+ storage: storage.adapter,
66
+ addMenuItem: false
67
+ });
68
+ controller = system.backups as BackupController;
69
+ session = system.createSession('graph');
70
+ });
71
+
72
+ afterEach(() => {
73
+ controller.dispose();
74
+ vi.useRealTimers();
75
+ });
76
+
77
+ const dirty = (nodes = [NODE('a')], edges: any[] = []) => {
78
+ session.nodeStore.getState().setNodes(nodes);
79
+ session.edgeStore.getState().setEdges(edges);
80
+ };
81
+
82
+ it('registers the autosave settings and seeds defaults', () => {
83
+ const keys = system.settingsSchema.getState().settings.map((s) => s.key);
84
+ expect(keys).toContain(AUTOSAVE_ENABLED);
85
+ expect(keys).toContain(AUTOSAVE_INTERVAL_SECONDS);
86
+ expect(keys).toContain(AUTOSAVE_MAX_COPIES);
87
+ expect(system.getSetting(AUTOSAVE_ENABLED)).toBe(false);
88
+ });
89
+
90
+ it('installs the controller on the system', () => {
91
+ expect(system.backups).toBeInstanceOf(Object);
92
+ expect(typeof controller.backupNow).toBe('function');
93
+ });
94
+
95
+ it('captures a forced snapshot of the focused graph', () => {
96
+ dirty([NODE('a'), NODE('b')]);
97
+ const snap = controller.backupNow();
98
+ expect(snap).not.toBeNull();
99
+ expect(snap?.nodeCount).toBe(2);
100
+ expect(controller.store.getState().snapshots).toHaveLength(1);
101
+ // Persisted to storage.
102
+ expect(new BackupStorage(storage.adapter).listAll()).toHaveLength(1);
103
+ });
104
+
105
+ it('does not capture an empty graph', () => {
106
+ expect(controller.backupNow()).toBeNull();
107
+ expect(controller.store.getState().snapshots).toHaveLength(0);
108
+ });
109
+
110
+ it('does not capture an inconsistent graph (edge to missing node)', () => {
111
+ dirty(
112
+ [NODE('a')],
113
+ [
114
+ {
115
+ id: 'e',
116
+ source: 'a',
117
+ target: 'ghost',
118
+ sourceHandle: 'f',
119
+ targetHandle: 'f'
120
+ }
121
+ ]
122
+ );
123
+ expect(controller.backupNow()).toBeNull();
124
+ expect(controller.store.getState().snapshots).toHaveLength(0);
125
+ });
126
+
127
+ it('skips a forced capture that duplicates the previous one', () => {
128
+ dirty([NODE('a')]);
129
+ expect(controller.backupNow()).not.toBeNull();
130
+ // No change since the last capture: identical, so skipped.
131
+ expect(controller.backupNow()).toBeNull();
132
+ expect(controller.store.getState().snapshots).toHaveLength(1);
133
+ });
134
+
135
+ it('does not capture while suspended', () => {
136
+ dirty([NODE('a')]);
137
+ controller.suspend();
138
+ expect(controller.backupNow()).toBeNull();
139
+ controller.resume();
140
+ expect(controller.backupNow()).not.toBeNull();
141
+ });
142
+
143
+ it('runExclusive brackets a region with no capture', () => {
144
+ dirty([NODE('a')]);
145
+ const inside = controller.runExclusive(() => controller.backupNow());
146
+ expect(inside).toBeNull();
147
+ expect(controller.store.getState().snapshots).toHaveLength(0);
148
+ });
149
+
150
+ it('trims each graph to the max-copies ring', () => {
151
+ system.setSetting(AUTOSAVE_MAX_COPIES, 3);
152
+ for (let i = 0; i < 5; i++) {
153
+ dirty([NODE('a', i, 0)]); // move node so each snapshot differs
154
+ controller.backupNow();
155
+ }
156
+ expect(controller.store.getState().snapshots).toHaveLength(3);
157
+ });
158
+
159
+ it('restores a snapshot into a new tab without touching the source graph', () => {
160
+ dirty([NODE('a'), NODE('b')]);
161
+ const snap = controller.backupNow()!;
162
+ const sessionsBefore = Object.keys(
163
+ system.activeGraph.getState().sessions
164
+ ).length;
165
+
166
+ const restored = controller.restore(snap.id);
167
+ expect(restored).toBeDefined();
168
+ expect(restored!.id).not.toBe(session.id);
169
+ expect(restored!.nodeStore.getState().nodes).toHaveLength(2);
170
+ expect(Object.keys(system.activeGraph.getState().sessions).length).toBe(
171
+ sessionsBefore + 1
172
+ );
173
+ // Source graph untouched.
174
+ expect(session.nodeStore.getState().nodes).toHaveLength(2);
175
+ });
176
+
177
+ it('runs the timer only while enabled and captures dirty, settled graphs', () => {
178
+ system.setSetting(AUTOSAVE_INTERVAL_SECONDS, 5);
179
+ system.setSetting(AUTOSAVE_ENABLED, true);
180
+ expect(controller.store.getState().running).toBe(true);
181
+
182
+ dirty([NODE('a')]);
183
+ // Advance past the interval AND the quiescence window.
184
+ vi.advanceTimersByTime(6000);
185
+ expect(controller.store.getState().snapshots).toHaveLength(1);
186
+
187
+ system.setSetting(AUTOSAVE_ENABLED, false);
188
+ expect(controller.store.getState().running).toBe(false);
189
+ });
190
+
191
+ it('deletes a single snapshot and clears all', () => {
192
+ dirty([NODE('a')]);
193
+ const snap = controller.backupNow()!;
194
+ dirty([NODE('a'), NODE('b')]);
195
+ controller.backupNow();
196
+ expect(controller.store.getState().snapshots).toHaveLength(2);
197
+
198
+ controller.deleteSnapshot(snap.id);
199
+ expect(controller.store.getState().snapshots).toHaveLength(1);
200
+
201
+ controller.clearAll();
202
+ expect(controller.store.getState().snapshots).toHaveLength(0);
203
+ });
204
+ });