@marktoflow/gui 2.0.0-alpha.3 → 2.0.0-alpha.5

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 (95) hide show
  1. package/.turbo/turbo-build.log +24 -8
  2. package/README.md +11 -1
  3. package/dist/client/assets/index-CM44OayM.js +704 -0
  4. package/dist/client/assets/index-CM44OayM.js.map +1 -0
  5. package/dist/client/assets/index-Dru63gi6.css +1 -0
  6. package/dist/client/index.html +2 -2
  7. package/dist/server/{server/index.js → index.js} +22 -1
  8. package/dist/server/index.js.map +1 -0
  9. package/dist/server/routes/executions.js +125 -0
  10. package/dist/server/routes/executions.js.map +1 -0
  11. package/dist/server/{server/routes → routes}/workflows.js +37 -1
  12. package/dist/server/routes/workflows.js.map +1 -0
  13. package/dist/server/{server/services → services}/WorkflowService.js +158 -15
  14. package/dist/server/services/WorkflowService.js.map +1 -0
  15. package/dist/server/{server/websocket → websocket}/index.js +12 -0
  16. package/dist/server/{server/websocket → websocket}/index.js.map +1 -1
  17. package/marktoflow-gui-2.0.0-alpha.5.tgz +0 -0
  18. package/package.json +20 -6
  19. package/scripts/flatten-dist.js +69 -0
  20. package/src/client/components/Canvas/Canvas.tsx +3 -1
  21. package/src/client/components/Canvas/ExecutionOverlay.tsx +120 -32
  22. package/src/client/components/Canvas/ForEachNode.tsx +27 -3
  23. package/src/client/components/Canvas/IfElseNode.tsx +22 -7
  24. package/src/client/components/Canvas/NodeContextMenu.tsx +8 -4
  25. package/src/client/components/Canvas/ParallelNode.tsx +25 -8
  26. package/src/client/components/Canvas/SwitchNode.tsx +41 -20
  27. package/src/client/components/Canvas/Toolbar.tsx +59 -21
  28. package/src/client/components/Canvas/TransformNode.tsx +9 -0
  29. package/src/client/components/Canvas/WhileNode.tsx +35 -3
  30. package/src/client/components/Debug/VariableInspector.tsx +148 -0
  31. package/src/client/components/Prompt/PromptInput.tsx +3 -1
  32. package/src/client/components/Settings/ProviderSwitcher.tsx +228 -0
  33. package/src/client/components/Sidebar/ImportDialog.tsx +257 -0
  34. package/src/client/components/Sidebar/Sidebar.tsx +21 -2
  35. package/src/client/components/common/KeyboardShortcuts.tsx +8 -2
  36. package/src/client/stores/agentStore.ts +109 -0
  37. package/src/client/stores/executionStore.ts +64 -2
  38. package/src/client/stores/workflowStore.ts +10 -2
  39. package/src/client/styles/globals.css +106 -0
  40. package/src/client/utils/platform.ts +46 -0
  41. package/src/client/utils/workflowToGraph.ts +245 -21
  42. package/src/server/index.ts +24 -1
  43. package/src/server/routes/executions.ts +136 -0
  44. package/src/server/routes/workflows.ts +42 -1
  45. package/src/server/services/WorkflowService.ts +176 -16
  46. package/src/server/websocket/index.ts +13 -0
  47. package/tests/unit/ForEachNode.test.tsx +96 -6
  48. package/tests/unit/IfElseNode.test.tsx +47 -0
  49. package/tests/unit/ParallelNode.test.tsx +80 -0
  50. package/tests/unit/SwitchNode.test.tsx +75 -0
  51. package/tests/unit/WhileNode.test.tsx +12 -8
  52. package/tests/unit/agentStore.test.ts +218 -0
  53. package/tests/unit/executionStore.test.ts +40 -0
  54. package/tests/unit/platform.test.ts +118 -0
  55. package/tests/unit/workflowToGraph.test.ts +22 -0
  56. package/dist/client/assets/index-C90Y_aBX.js +0 -678
  57. package/dist/client/assets/index-C90Y_aBX.js.map +0 -1
  58. package/dist/client/assets/index-CRWeQ3NN.css +0 -1
  59. package/dist/server/server/index.js.map +0 -1
  60. package/dist/server/server/routes/workflows.js.map +0 -1
  61. package/dist/server/server/services/WorkflowService.js.map +0 -1
  62. /package/dist/server/{server/routes → routes}/ai.js +0 -0
  63. /package/dist/server/{server/routes → routes}/ai.js.map +0 -0
  64. /package/dist/server/{server/routes → routes}/execute.js +0 -0
  65. /package/dist/server/{server/routes → routes}/execute.js.map +0 -0
  66. /package/dist/server/{server/routes → routes}/tools.js +0 -0
  67. /package/dist/server/{server/routes → routes}/tools.js.map +0 -0
  68. /package/dist/server/{server/services → services}/AIService.js +0 -0
  69. /package/dist/server/{server/services → services}/AIService.js.map +0 -0
  70. /package/dist/server/{server/services → services}/FileWatcher.js +0 -0
  71. /package/dist/server/{server/services → services}/FileWatcher.js.map +0 -0
  72. /package/dist/server/{server/services → services}/agents/claude-code-provider.js +0 -0
  73. /package/dist/server/{server/services → services}/agents/claude-code-provider.js.map +0 -0
  74. /package/dist/server/{server/services → services}/agents/claude-provider.js +0 -0
  75. /package/dist/server/{server/services → services}/agents/claude-provider.js.map +0 -0
  76. /package/dist/server/{server/services → services}/agents/codex-provider.js +0 -0
  77. /package/dist/server/{server/services → services}/agents/codex-provider.js.map +0 -0
  78. /package/dist/server/{server/services → services}/agents/copilot-provider.js +0 -0
  79. /package/dist/server/{server/services → services}/agents/copilot-provider.js.map +0 -0
  80. /package/dist/server/{server/services → services}/agents/demo-provider.js +0 -0
  81. /package/dist/server/{server/services → services}/agents/demo-provider.js.map +0 -0
  82. /package/dist/server/{server/services → services}/agents/index.js +0 -0
  83. /package/dist/server/{server/services → services}/agents/index.js.map +0 -0
  84. /package/dist/server/{server/services → services}/agents/ollama-provider.js +0 -0
  85. /package/dist/server/{server/services → services}/agents/ollama-provider.js.map +0 -0
  86. /package/dist/server/{server/services → services}/agents/prompts.js +0 -0
  87. /package/dist/server/{server/services → services}/agents/prompts.js.map +0 -0
  88. /package/dist/server/{server/services → services}/agents/registry.js +0 -0
  89. /package/dist/server/{server/services → services}/agents/registry.js.map +0 -0
  90. /package/dist/server/{server/services → services}/agents/types.js +0 -0
  91. /package/dist/server/{server/services → services}/agents/types.js.map +0 -0
  92. /package/dist/{server/shared → shared}/constants.js +0 -0
  93. /package/dist/{server/shared → shared}/constants.js.map +0 -0
  94. /package/dist/{server/shared → shared}/types.js +0 -0
  95. /package/dist/{server/shared → shared}/types.js.map +0 -0
@@ -185,4 +185,51 @@ describe('IfElseNode', () => {
185
185
  expect(screen.getByText(complexCondition)).toBeInTheDocument();
186
186
  });
187
187
  });
188
+
189
+ describe('skipped branch indicators', () => {
190
+ it('should show SKIP badge on skipped then branch', () => {
191
+ renderNode({ skippedBranch: 'then' });
192
+
193
+ expect(screen.getByText('SKIP')).toBeInTheDocument();
194
+ const thenBranch = screen.getByText('✓ Then');
195
+ expect(thenBranch).toHaveClass('bg-gray-500/20', 'text-gray-400');
196
+ });
197
+
198
+ it('should show SKIP badge on skipped else branch', () => {
199
+ renderNode({ skippedBranch: 'else' });
200
+
201
+ expect(screen.getByText('SKIP')).toBeInTheDocument();
202
+ const elseBranch = screen.getByText('✗ Else');
203
+ expect(elseBranch).toHaveClass('bg-gray-500/20', 'text-gray-400');
204
+ });
205
+
206
+ it('should highlight active branch with ring when one is active', () => {
207
+ renderNode({ activeBranch: 'then' });
208
+
209
+ const thenBranch = screen.getByText('✓ Then');
210
+ expect(thenBranch).toHaveClass('ring-1', 'ring-green-400/50');
211
+ });
212
+
213
+ it('should not show SKIP when no branch is skipped', () => {
214
+ renderNode({ skippedBranch: null });
215
+
216
+ expect(screen.queryByText('SKIP')).not.toBeInTheDocument();
217
+ });
218
+ });
219
+
220
+ describe('completed and failed states', () => {
221
+ it('should show completed class on node', () => {
222
+ renderNode({ status: 'completed' });
223
+
224
+ const node = screen.getByText('Check Count').closest('.control-flow-node');
225
+ expect(node).toHaveClass('completed');
226
+ });
227
+
228
+ it('should show failed class on node', () => {
229
+ renderNode({ status: 'failed' });
230
+
231
+ const node = screen.getByText('Check Count').closest('.control-flow-node');
232
+ expect(node).toHaveClass('failed');
233
+ });
234
+ });
188
235
  });
@@ -261,4 +261,84 @@ describe('ParallelNode', () => {
261
261
  expect(screen.getByText('0')).toBeInTheDocument();
262
262
  });
263
263
  });
264
+
265
+ describe('failed branches', () => {
266
+ it('should highlight failed branches in red', () => {
267
+ renderNode({
268
+ failedBranches: ['branch-1'],
269
+ });
270
+
271
+ const jiraBranch = screen.getByText('Jira');
272
+ expect(jiraBranch).toHaveClass('bg-red-500/30', 'text-red-200');
273
+ });
274
+
275
+ it('should prioritize failed over completed and active', () => {
276
+ renderNode({
277
+ activeBranches: ['branch-1'],
278
+ completedBranches: ['branch-1'],
279
+ failedBranches: ['branch-1'],
280
+ });
281
+
282
+ const jiraBranch = screen.getByText('Jira');
283
+ expect(jiraBranch).toHaveClass('bg-red-500/30', 'text-red-200');
284
+ expect(jiraBranch).not.toHaveClass('bg-green-500/30');
285
+ expect(jiraBranch).not.toHaveClass('animate-pulse');
286
+ });
287
+ });
288
+
289
+ describe('max concurrent exceeded', () => {
290
+ it('should show rate limiting warning when maxConcurrentExceeded is true', () => {
291
+ renderNode({
292
+ maxConcurrent: 2,
293
+ maxConcurrentExceeded: true,
294
+ });
295
+
296
+ expect(screen.getByText('Rate limiting active')).toBeInTheDocument();
297
+ });
298
+
299
+ it('should highlight max concurrent value in yellow when exceeded', () => {
300
+ renderNode({
301
+ maxConcurrent: 2,
302
+ maxConcurrentExceeded: true,
303
+ });
304
+
305
+ const maxValue = screen.getByText('2');
306
+ expect(maxValue).toHaveClass('text-yellow-300');
307
+ });
308
+
309
+ it('should not show warning when maxConcurrentExceeded is false', () => {
310
+ renderNode({
311
+ maxConcurrent: 2,
312
+ maxConcurrentExceeded: false,
313
+ });
314
+
315
+ expect(screen.queryByText('Rate limiting active')).not.toBeInTheDocument();
316
+ });
317
+
318
+ it('should not highlight max concurrent value when not exceeded', () => {
319
+ renderNode({
320
+ maxConcurrent: 2,
321
+ maxConcurrentExceeded: false,
322
+ });
323
+
324
+ const maxValue = screen.getByText('2');
325
+ expect(maxValue).not.toHaveClass('text-yellow-300');
326
+ });
327
+ });
328
+
329
+ describe('completed and failed states', () => {
330
+ it('should show completed class on node', () => {
331
+ renderNode({ status: 'completed' });
332
+
333
+ const node = screen.getByText('Fetch Data').closest('.control-flow-node');
334
+ expect(node).toHaveClass('completed');
335
+ });
336
+
337
+ it('should show failed class on node', () => {
338
+ renderNode({ status: 'failed' });
339
+
340
+ const node = screen.getByText('Fetch Data').closest('.control-flow-node');
341
+ expect(node).toHaveClass('failed');
342
+ });
343
+ });
264
344
  });
@@ -249,4 +249,79 @@ describe('SwitchNode', () => {
249
249
  expect(screen.getByText('0 cases + default')).toBeInTheDocument();
250
250
  });
251
251
  });
252
+
253
+ describe('skipped branches', () => {
254
+ it('should show SKIPPED badge on skipped case', () => {
255
+ renderNode({
256
+ skippedBranches: ['high', 'medium'],
257
+ activeCase: 'critical'
258
+ });
259
+
260
+ const skippedBadges = screen.getAllByText('SKIPPED');
261
+ expect(skippedBadges).toHaveLength(2);
262
+
263
+ const highCase = screen.getByText('high');
264
+ expect(highCase).toHaveClass('line-through', 'text-gray-400');
265
+ });
266
+
267
+ it('should not show SKIPPED when no branches are skipped', () => {
268
+ renderNode({ skippedBranches: [] });
269
+
270
+ expect(screen.queryByText('SKIPPED')).not.toBeInTheDocument();
271
+ });
272
+
273
+ it('should show active case with ring highlight', () => {
274
+ renderNode({ activeCase: 'critical' });
275
+
276
+ const criticalCase = screen.getByText('critical');
277
+ expect(criticalCase).toHaveClass('ring-1', 'ring-purple-400/50');
278
+ });
279
+ });
280
+
281
+ describe('handle positioning', () => {
282
+ it('should render output handles for all visible cases', () => {
283
+ const { container } = renderNode({
284
+ cases: { critical: {}, high: {}, medium: {} },
285
+ hasDefault: true
286
+ });
287
+
288
+ // Should have 4 handles: 3 cases + 1 default
289
+ const handles = container.querySelectorAll('.react-flow__handle-bottom');
290
+ expect(handles.length).toBeGreaterThanOrEqual(3);
291
+ });
292
+
293
+ it('should position handles evenly to avoid overlap', () => {
294
+ const { container } = renderNode({
295
+ cases: { case1: {}, case2: {}, case3: {}, case4: {} },
296
+ hasDefault: true
297
+ });
298
+
299
+ const handles = container.querySelectorAll('.react-flow__handle-bottom');
300
+
301
+ // Each handle should have a unique position
302
+ const positions = Array.from(handles).map(h =>
303
+ (h as HTMLElement).style.left
304
+ );
305
+
306
+ // All positions should be different
307
+ const uniquePositions = new Set(positions);
308
+ expect(uniquePositions.size).toBe(positions.length);
309
+ });
310
+ });
311
+
312
+ describe('completed and failed states', () => {
313
+ it('should show completed class on node', () => {
314
+ renderNode({ status: 'completed' });
315
+
316
+ const node = screen.getByText('Route By Severity').closest('.control-flow-node');
317
+ expect(node).toHaveClass('completed');
318
+ });
319
+
320
+ it('should show failed class on node', () => {
321
+ renderNode({ status: 'failed' });
322
+
323
+ const node = screen.getByText('Route By Severity').closest('.control-flow-node');
324
+ expect(node).toHaveClass('failed');
325
+ });
326
+ });
252
327
  });
@@ -123,8 +123,9 @@ describe('WhileNode', () => {
123
123
  maxIterations: 10,
124
124
  });
125
125
 
126
- const progressBar = container.querySelector('.bg-orange-400');
127
- expect(progressBar).toHaveStyle({ width: '50%' });
126
+ const progressBar = container.querySelector('.bg-orange-400, .bg-orange-500');
127
+ expect(progressBar).toBeInTheDocument();
128
+ expect(progressBar).toHaveAttribute('style', expect.stringContaining('50%'));
128
129
  });
129
130
 
130
131
  it('should display 0% progress when currentIteration is 0', () => {
@@ -133,8 +134,9 @@ describe('WhileNode', () => {
133
134
  maxIterations: 10,
134
135
  });
135
136
 
136
- const progressBar = container.querySelector('.bg-orange-400');
137
- expect(progressBar).toHaveStyle({ width: '0%' });
137
+ const progressBar = container.querySelector('.bg-orange-400, .bg-orange-500');
138
+ expect(progressBar).toBeInTheDocument();
139
+ expect(progressBar).toHaveAttribute('style', expect.stringContaining('0%'));
138
140
  });
139
141
 
140
142
  it('should display 100% progress when at max iterations', () => {
@@ -143,8 +145,9 @@ describe('WhileNode', () => {
143
145
  maxIterations: 10,
144
146
  });
145
147
 
146
- const progressBar = container.querySelector('.bg-orange-400');
147
- expect(progressBar).toHaveStyle({ width: '100%' });
148
+ const progressBar = container.querySelector('.bg-orange-400, .bg-orange-500');
149
+ expect(progressBar).toBeInTheDocument();
150
+ expect(progressBar).toHaveAttribute('style', expect.stringContaining('100%'));
148
151
  });
149
152
 
150
153
  it('should not display progress when currentIteration is undefined', () => {
@@ -219,8 +222,9 @@ describe('WhileNode', () => {
219
222
  });
220
223
 
221
224
  expect(screen.getByText('25 / 50')).toBeInTheDocument();
222
- const progressBar = container.querySelector('.bg-orange-400');
223
- expect(progressBar).toHaveStyle({ width: '50%' });
225
+ const progressBar = container.querySelector('.bg-orange-400, .bg-orange-500');
226
+ expect(progressBar).toBeInTheDocument();
227
+ expect(progressBar).toHaveAttribute('style', expect.stringContaining('50%'));
224
228
  });
225
229
  });
226
230
  });
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Tests for agent store
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
6
+ import { useAgentStore } from '../../src/client/stores/agentStore';
7
+
8
+ // Mock fetch
9
+ global.fetch = vi.fn();
10
+
11
+ describe('Agent Store', () => {
12
+ beforeEach(() => {
13
+ // Reset store
14
+ useAgentStore.setState({
15
+ providers: [],
16
+ activeProviderId: null,
17
+ isLoading: false,
18
+ error: null,
19
+ });
20
+
21
+ // Reset fetch mock
22
+ vi.clearAllMocks();
23
+ });
24
+
25
+ describe('loadProviders', () => {
26
+ it('should load providers successfully', async () => {
27
+ const mockProviders = [
28
+ {
29
+ id: 'copilot',
30
+ name: 'GitHub Copilot',
31
+ status: 'ready',
32
+ isActive: true,
33
+ },
34
+ {
35
+ id: 'claude',
36
+ name: 'Claude Code',
37
+ status: 'needs_config',
38
+ isActive: false,
39
+ },
40
+ ];
41
+
42
+ (global.fetch as any).mockResolvedValueOnce({
43
+ ok: true,
44
+ json: async () => ({
45
+ activeProvider: 'copilot',
46
+ providers: mockProviders,
47
+ }),
48
+ });
49
+
50
+ const { loadProviders } = useAgentStore.getState();
51
+ await loadProviders();
52
+
53
+ const state = useAgentStore.getState();
54
+ expect(state.providers).toEqual(mockProviders);
55
+ expect(state.activeProviderId).toBe('copilot');
56
+ expect(state.isLoading).toBe(false);
57
+ expect(state.error).toBe(null);
58
+ });
59
+
60
+ it('should handle loading errors', async () => {
61
+ (global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
62
+
63
+ const { loadProviders } = useAgentStore.getState();
64
+ await loadProviders();
65
+
66
+ const state = useAgentStore.getState();
67
+ expect(state.error).toBe('Network error');
68
+ expect(state.isLoading).toBe(false);
69
+ });
70
+
71
+ it('should handle HTTP errors', async () => {
72
+ (global.fetch as any).mockResolvedValueOnce({
73
+ ok: false,
74
+ status: 500,
75
+ });
76
+
77
+ const { loadProviders } = useAgentStore.getState();
78
+ await loadProviders();
79
+
80
+ const state = useAgentStore.getState();
81
+ expect(state.error).toBe('Failed to load providers');
82
+ expect(state.isLoading).toBe(false);
83
+ });
84
+
85
+ it('should set loading state during fetch', async () => {
86
+ let resolvePromise: any;
87
+ const promise = new Promise((resolve) => {
88
+ resolvePromise = resolve;
89
+ });
90
+
91
+ (global.fetch as any).mockReturnValueOnce(promise);
92
+
93
+ const { loadProviders } = useAgentStore.getState();
94
+ const loadPromise = loadProviders();
95
+
96
+ // Check loading state
97
+ expect(useAgentStore.getState().isLoading).toBe(true);
98
+
99
+ // Resolve promise
100
+ resolvePromise({
101
+ ok: true,
102
+ json: async () => ({ activeProvider: null, providers: [] }),
103
+ });
104
+
105
+ await loadPromise;
106
+ expect(useAgentStore.getState().isLoading).toBe(false);
107
+ });
108
+ });
109
+
110
+ describe('setProvider', () => {
111
+ it('should set provider successfully', async () => {
112
+ const mockProviders = [
113
+ {
114
+ id: 'copilot',
115
+ name: 'GitHub Copilot',
116
+ status: 'ready',
117
+ isActive: false,
118
+ },
119
+ {
120
+ id: 'claude',
121
+ name: 'Claude Code',
122
+ status: 'ready',
123
+ isActive: true,
124
+ },
125
+ ];
126
+
127
+ (global.fetch as any).mockResolvedValueOnce({
128
+ ok: true,
129
+ json: async () => ({
130
+ success: true,
131
+ status: {
132
+ activeProvider: 'claude',
133
+ providers: mockProviders,
134
+ },
135
+ }),
136
+ });
137
+
138
+ const { setProvider } = useAgentStore.getState();
139
+ const result = await setProvider('claude');
140
+
141
+ expect(result).toBe(true);
142
+ const state = useAgentStore.getState();
143
+ expect(state.activeProviderId).toBe('claude');
144
+ expect(state.providers).toEqual(mockProviders);
145
+ });
146
+
147
+ it('should send configuration with request', async () => {
148
+ (global.fetch as any).mockResolvedValueOnce({
149
+ ok: true,
150
+ json: async () => ({
151
+ success: true,
152
+ status: { activeProvider: 'claude', providers: [] },
153
+ }),
154
+ });
155
+
156
+ const { setProvider } = useAgentStore.getState();
157
+ await setProvider('claude', {
158
+ apiKey: 'test-key',
159
+ baseUrl: 'https://api.test.com',
160
+ model: 'claude-3',
161
+ });
162
+
163
+ expect(global.fetch).toHaveBeenCalledWith(
164
+ '/api/ai/providers/claude',
165
+ expect.objectContaining({
166
+ method: 'POST',
167
+ headers: {
168
+ 'Content-Type': 'application/json',
169
+ },
170
+ body: JSON.stringify({
171
+ apiKey: 'test-key',
172
+ baseUrl: 'https://api.test.com',
173
+ model: 'claude-3',
174
+ }),
175
+ })
176
+ );
177
+ });
178
+
179
+ it('should handle set provider errors', async () => {
180
+ (global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
181
+
182
+ const { setProvider } = useAgentStore.getState();
183
+ const result = await setProvider('claude');
184
+
185
+ expect(result).toBe(false);
186
+ const state = useAgentStore.getState();
187
+ expect(state.error).toBe('Network error');
188
+ });
189
+
190
+ it('should handle HTTP errors', async () => {
191
+ (global.fetch as any).mockResolvedValueOnce({
192
+ ok: false,
193
+ json: async () => ({ message: 'Provider not found' }),
194
+ });
195
+
196
+ const { setProvider } = useAgentStore.getState();
197
+ const result = await setProvider('invalid');
198
+
199
+ expect(result).toBe(false);
200
+ const state = useAgentStore.getState();
201
+ expect(state.error).toBe('Provider not found');
202
+ });
203
+ });
204
+
205
+ describe('refreshStatus', () => {
206
+ it('should call loadProviders', async () => {
207
+ (global.fetch as any).mockResolvedValueOnce({
208
+ ok: true,
209
+ json: async () => ({ activeProvider: null, providers: [] }),
210
+ });
211
+
212
+ const { refreshStatus } = useAgentStore.getState();
213
+ await refreshStatus();
214
+
215
+ expect(global.fetch).toHaveBeenCalledWith('/api/ai/providers');
216
+ });
217
+ });
218
+ });
@@ -101,6 +101,46 @@ describe('executionStore', () => {
101
101
  const run = state.runs.find(r => r.id === runId);
102
102
  expect(run?.steps[0].error).toBe('Something went wrong');
103
103
  });
104
+
105
+ it('should record step inputs', () => {
106
+ const { startExecution, updateStepStatus } = useExecutionStore.getState();
107
+
108
+ const runId = startExecution('wf-1', 'Test');
109
+ const inputs = { channel: '#general', message: 'Hello' };
110
+ updateStepStatus(runId, 'step-1', 'running', undefined, undefined, undefined, inputs);
111
+
112
+ const state = useExecutionStore.getState();
113
+ const run = state.runs.find(r => r.id === runId);
114
+ expect(run?.steps[0].inputs).toEqual(inputs);
115
+ });
116
+
117
+ it('should record step output and output variable', () => {
118
+ const { startExecution, updateStepStatus } = useExecutionStore.getState();
119
+
120
+ const runId = startExecution('wf-1', 'Test');
121
+ const output = { ts: '1234567890.123456', ok: true };
122
+ updateStepStatus(runId, 'step-1', 'running');
123
+ updateStepStatus(runId, 'step-1', 'completed', output, undefined, 'result');
124
+
125
+ const state = useExecutionStore.getState();
126
+ const run = state.runs.find(r => r.id === runId);
127
+ expect(run?.steps[0].output).toEqual(output);
128
+ expect(run?.steps[0].outputVariable).toBe('result');
129
+ });
130
+
131
+ it('should preserve inputs when updating step status', () => {
132
+ const { startExecution, updateStepStatus } = useExecutionStore.getState();
133
+
134
+ const runId = startExecution('wf-1', 'Test');
135
+ const inputs = { foo: 'bar' };
136
+ updateStepStatus(runId, 'step-1', 'running', undefined, undefined, undefined, inputs);
137
+ updateStepStatus(runId, 'step-1', 'completed', { result: 'success' });
138
+
139
+ const state = useExecutionStore.getState();
140
+ const run = state.runs.find(r => r.id === runId);
141
+ expect(run?.steps[0].inputs).toEqual(inputs);
142
+ expect(run?.steps[0].output).toEqual({ result: 'success' });
143
+ });
104
144
  });
105
145
 
106
146
  describe('completeExecution', () => {
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Tests for platform detection utilities
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
6
+ import { isMac, getModKey, getShortcutDisplay, isModKeyPressed } from '../../src/client/utils/platform';
7
+
8
+ describe('Platform Utilities', () => {
9
+ describe('isMac', () => {
10
+ beforeEach(() => {
11
+ // Reset navigator mock
12
+ vi.stubGlobal('navigator', {
13
+ platform: 'MacIntel',
14
+ });
15
+ });
16
+
17
+ it('should return true for Mac platforms', () => {
18
+ vi.stubGlobal('navigator', { platform: 'MacIntel' });
19
+ expect(isMac()).toBe(true);
20
+
21
+ vi.stubGlobal('navigator', { platform: 'MacPPC' });
22
+ expect(isMac()).toBe(true);
23
+
24
+ vi.stubGlobal('navigator', { platform: 'Mac68K' });
25
+ expect(isMac()).toBe(true);
26
+ });
27
+
28
+ it('should return false for non-Mac platforms', () => {
29
+ vi.stubGlobal('navigator', { platform: 'Win32' });
30
+ expect(isMac()).toBe(false);
31
+
32
+ vi.stubGlobal('navigator', { platform: 'Linux x86_64' });
33
+ expect(isMac()).toBe(false);
34
+
35
+ vi.stubGlobal('navigator', { platform: 'Linux' });
36
+ expect(isMac()).toBe(false);
37
+ });
38
+
39
+ it('should return false when navigator is undefined', () => {
40
+ vi.stubGlobal('navigator', undefined);
41
+ expect(isMac()).toBe(false);
42
+ });
43
+ });
44
+
45
+ describe('getModKey', () => {
46
+ it('should return ⌘ on Mac', () => {
47
+ vi.stubGlobal('navigator', { platform: 'MacIntel' });
48
+ expect(getModKey()).toBe('⌘');
49
+ });
50
+
51
+ it('should return Ctrl on Windows', () => {
52
+ vi.stubGlobal('navigator', { platform: 'Win32' });
53
+ expect(getModKey()).toBe('Ctrl');
54
+ });
55
+
56
+ it('should return Ctrl on Linux', () => {
57
+ vi.stubGlobal('navigator', { platform: 'Linux x86_64' });
58
+ expect(getModKey()).toBe('Ctrl');
59
+ });
60
+ });
61
+
62
+ describe('getShortcutDisplay', () => {
63
+ it('should convert ⌘ to Ctrl on Windows', () => {
64
+ vi.stubGlobal('navigator', { platform: 'Win32' });
65
+ expect(getShortcutDisplay('⌘+S')).toBe('Ctrl+S');
66
+ expect(getShortcutDisplay('⌘+Z')).toBe('Ctrl+Z');
67
+ expect(getShortcutDisplay('⌘+⇧+Z')).toBe('Ctrl+⇧+Z');
68
+ });
69
+
70
+ it('should keep ⌘ on Mac', () => {
71
+ vi.stubGlobal('navigator', { platform: 'MacIntel' });
72
+ expect(getShortcutDisplay('⌘+S')).toBe('⌘+S');
73
+ expect(getShortcutDisplay('⌘+Z')).toBe('⌘+Z');
74
+ });
75
+
76
+ it('should convert Cmd to appropriate modifier', () => {
77
+ vi.stubGlobal('navigator', { platform: 'Win32' });
78
+ expect(getShortcutDisplay('Cmd+S')).toBe('Ctrl+S');
79
+
80
+ vi.stubGlobal('navigator', { platform: 'MacIntel' });
81
+ expect(getShortcutDisplay('Cmd+S')).toBe('⌘+S');
82
+ });
83
+
84
+ it('should handle multiple occurrences', () => {
85
+ vi.stubGlobal('navigator', { platform: 'Win32' });
86
+ expect(getShortcutDisplay('⌘+⌘')).toBe('Ctrl+Ctrl');
87
+ });
88
+ });
89
+
90
+ describe('isModKeyPressed', () => {
91
+ it('should check metaKey on Mac', () => {
92
+ vi.stubGlobal('navigator', { platform: 'MacIntel' });
93
+
94
+ const event = { metaKey: true, ctrlKey: false } as KeyboardEvent;
95
+ expect(isModKeyPressed(event)).toBe(true);
96
+
97
+ const event2 = { metaKey: false, ctrlKey: true } as KeyboardEvent;
98
+ expect(isModKeyPressed(event2)).toBe(false);
99
+ });
100
+
101
+ it('should check ctrlKey on Windows', () => {
102
+ vi.stubGlobal('navigator', { platform: 'Win32' });
103
+
104
+ const event = { metaKey: false, ctrlKey: true } as KeyboardEvent;
105
+ expect(isModKeyPressed(event)).toBe(true);
106
+
107
+ const event2 = { metaKey: true, ctrlKey: false } as KeyboardEvent;
108
+ expect(isModKeyPressed(event2)).toBe(false);
109
+ });
110
+
111
+ it('should check ctrlKey on Linux', () => {
112
+ vi.stubGlobal('navigator', { platform: 'Linux x86_64' });
113
+
114
+ const event = { metaKey: false, ctrlKey: true } as KeyboardEvent;
115
+ expect(isModKeyPressed(event)).toBe(true);
116
+ });
117
+ });
118
+ });
@@ -49,6 +49,28 @@ describe('workflowToGraph', () => {
49
49
  expect(stepNode?.data.action).toBe('github.pulls.get');
50
50
  expect(stepNode?.data.status).toBe('pending');
51
51
  });
52
+
53
+ it('should space steps with 180px vertical spacing', () => {
54
+ const workflow = {
55
+ metadata: { id: 'test-1', name: 'Test Workflow' },
56
+ steps: [
57
+ { id: 'step-1', name: 'Step 1', action: 'test', inputs: {} },
58
+ { id: 'step-2', name: 'Step 2', action: 'test', inputs: {} },
59
+ { id: 'step-3', name: 'Step 3', action: 'test', inputs: {} },
60
+ ],
61
+ };
62
+
63
+ const { nodes } = workflowToGraph(workflow);
64
+ const stepNodes = nodes.filter(n => n.type === 'step');
65
+
66
+ // Check vertical spacing between steps
67
+ const step1Y = stepNodes[0].position.y;
68
+ const step2Y = stepNodes[1].position.y;
69
+ const step3Y = stepNodes[2].position.y;
70
+
71
+ expect(step2Y - step1Y).toBe(180);
72
+ expect(step3Y - step2Y).toBe(180);
73
+ });
52
74
  });
53
75
 
54
76
  describe('trigger nodes', () => {