@mrpatronz/nexusflow 0.2.17 → 0.2.18

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 (274) hide show
  1. package/.agents/skills/ccc/SKILL.md +71 -0
  2. package/.agents/skills/ccc/references/management.md +110 -0
  3. package/.agents/skills/ccc/references/settings.md +126 -0
  4. package/.github/dependabot.yml +52 -52
  5. package/.github/workflows/ci.yml +44 -0
  6. package/.github/workflows/dependabot-merge.yml +53 -53
  7. package/.github/workflows/release-desktop.yml +67 -0
  8. package/.github/workflows/release-npm.yml +65 -0
  9. package/.github/workflows/release-vscode.yml +61 -0
  10. package/.vscode/launch.json +17 -17
  11. package/.vscode/tasks.json +17 -17
  12. package/GETTING_STARTED.md +114 -114
  13. package/README.md +364 -336
  14. package/desktop/build-installer.js +228 -228
  15. package/desktop/build.js +118 -118
  16. package/desktop/installer/nexusflow.iss +35 -35
  17. package/desktop/neutralino.config.json +38 -38
  18. package/desktop/package-lock.json +1060 -1060
  19. package/desktop/package.json +14 -14
  20. package/dist/commands/adapter.d.ts +27 -0
  21. package/dist/commands/adapter.d.ts.map +1 -0
  22. package/dist/commands/adapter.js +318 -0
  23. package/dist/commands/adapter.js.map +1 -0
  24. package/dist/commands/add-repo.d.ts.map +1 -1
  25. package/dist/commands/add-repo.js +0 -1
  26. package/dist/commands/add-repo.js.map +1 -1
  27. package/dist/commands/commands.test.js +2 -2
  28. package/dist/commands/commands.test.js.map +1 -1
  29. package/dist/commands/config.d.ts +22 -0
  30. package/dist/commands/config.d.ts.map +1 -0
  31. package/dist/commands/config.js +50 -0
  32. package/dist/commands/config.js.map +1 -0
  33. package/dist/commands/create.d.ts.map +1 -1
  34. package/dist/commands/create.js +14 -17
  35. package/dist/commands/create.js.map +1 -1
  36. package/dist/commands/doctor.d.ts.map +1 -1
  37. package/dist/commands/doctor.js +11 -0
  38. package/dist/commands/doctor.js.map +1 -1
  39. package/dist/commands/refresh.d.ts +1 -0
  40. package/dist/commands/refresh.d.ts.map +1 -1
  41. package/dist/commands/refresh.js +2 -14
  42. package/dist/commands/refresh.js.map +1 -1
  43. package/dist/commands/sync.d.ts.map +1 -1
  44. package/dist/commands/sync.js +0 -5
  45. package/dist/commands/sync.js.map +1 -1
  46. package/dist/commands/tui.d.ts.map +1 -1
  47. package/dist/commands/tui.js +63 -10
  48. package/dist/commands/tui.js.map +1 -1
  49. package/dist/core/adapters/local-storage.d.ts +14 -0
  50. package/dist/core/adapters/local-storage.d.ts.map +1 -0
  51. package/dist/core/adapters/local-storage.js +58 -0
  52. package/dist/core/adapters/local-storage.js.map +1 -0
  53. package/dist/core/adapters/obsidian-storage.d.ts +28 -0
  54. package/dist/core/adapters/obsidian-storage.d.ts.map +1 -0
  55. package/dist/core/adapters/obsidian-storage.js +135 -0
  56. package/dist/core/adapters/obsidian-storage.js.map +1 -0
  57. package/dist/core/adapters/registry.d.ts +8 -0
  58. package/dist/core/adapters/registry.d.ts.map +1 -0
  59. package/dist/core/adapters/registry.js +37 -0
  60. package/dist/core/adapters/registry.js.map +1 -0
  61. package/dist/core/adapters/storage.test.d.ts +2 -0
  62. package/dist/core/adapters/storage.test.d.ts.map +1 -0
  63. package/dist/core/adapters/storage.test.js +106 -0
  64. package/dist/core/adapters/storage.test.js.map +1 -0
  65. package/dist/core/adapters/vault-storage.d.ts +16 -0
  66. package/dist/core/adapters/vault-storage.d.ts.map +1 -0
  67. package/dist/core/adapters/vault-storage.js +77 -0
  68. package/dist/core/adapters/vault-storage.js.map +1 -0
  69. package/dist/core/config.d.ts.map +1 -1
  70. package/dist/core/config.js +28 -4
  71. package/dist/core/config.js.map +1 -1
  72. package/dist/core/plugins/index.d.ts +14 -0
  73. package/dist/core/plugins/index.d.ts.map +1 -0
  74. package/dist/core/plugins/index.js +2 -0
  75. package/dist/core/plugins/index.js.map +1 -0
  76. package/dist/core/plugins/loader.d.ts +2 -0
  77. package/dist/core/plugins/loader.d.ts.map +1 -0
  78. package/dist/core/plugins/loader.js +34 -0
  79. package/dist/core/plugins/loader.js.map +1 -0
  80. package/dist/core/ports/storage.d.ts +59 -0
  81. package/dist/core/ports/storage.d.ts.map +1 -0
  82. package/dist/core/ports/storage.js +10 -0
  83. package/dist/core/ports/storage.js.map +1 -0
  84. package/dist/core/storage.d.ts +10 -0
  85. package/dist/core/storage.d.ts.map +1 -0
  86. package/dist/core/storage.js +29 -0
  87. package/dist/core/storage.js.map +1 -0
  88. package/dist/core/workspace.d.ts.map +1 -1
  89. package/dist/core/workspace.js +110 -16
  90. package/dist/core/workspace.js.map +1 -1
  91. package/dist/generators/base.d.ts.map +1 -1
  92. package/dist/generators/base.js +80 -79
  93. package/dist/generators/base.js.map +1 -1
  94. package/dist/generators/codex.js +11 -11
  95. package/dist/generators/copilot.js +17 -17
  96. package/dist/generators/cursor.js +5 -5
  97. package/dist/generators/diff-context.d.ts +6 -0
  98. package/dist/generators/diff-context.d.ts.map +1 -0
  99. package/dist/generators/diff-context.js +68 -0
  100. package/dist/generators/diff-context.js.map +1 -0
  101. package/dist/generators/index.d.ts +1 -1
  102. package/dist/generators/index.d.ts.map +1 -1
  103. package/dist/generators/index.js +125 -68
  104. package/dist/generators/index.js.map +1 -1
  105. package/dist/generators/map-generator.d.ts.map +1 -1
  106. package/dist/generators/map-generator.js +21 -25
  107. package/dist/generators/map-generator.js.map +1 -1
  108. package/dist/generators/plan-generator.d.ts.map +1 -1
  109. package/dist/generators/plan-generator.js +3 -5
  110. package/dist/generators/plan-generator.js.map +1 -1
  111. package/dist/generators/skills-generator.js +28 -28
  112. package/dist/gui/assets/index-CB-jWded.css +1 -0
  113. package/dist/gui/assets/index-DDXpZZu3.js +25 -0
  114. package/dist/gui/icons.svg +24 -24
  115. package/dist/gui/index.html +18 -18
  116. package/dist/index.js +131 -20
  117. package/dist/index.js.map +1 -1
  118. package/dist/server.d.ts.map +1 -1
  119. package/dist/server.js +46 -43
  120. package/dist/server.js.map +1 -1
  121. package/dist/server.test.js +105 -48
  122. package/dist/server.test.js.map +1 -1
  123. package/dist/types.d.ts +7 -2
  124. package/dist/types.d.ts.map +1 -1
  125. package/dist/utils/local-ai.d.ts.map +1 -1
  126. package/dist/utils/local-ai.js +7 -3
  127. package/dist/utils/local-ai.js.map +1 -1
  128. package/dist/utils/update-check.d.ts.map +1 -1
  129. package/dist/utils/update-check.js +0 -28
  130. package/dist/utils/update-check.js.map +1 -1
  131. package/dist/utils/workflow-advisor.d.ts +16 -0
  132. package/dist/utils/workflow-advisor.d.ts.map +1 -0
  133. package/dist/utils/workflow-advisor.js +100 -0
  134. package/dist/utils/workflow-advisor.js.map +1 -0
  135. package/extension/package-lock.json +6044 -2673
  136. package/extension/package.json +117 -108
  137. package/extension/src/extension.ts +653 -631
  138. package/extension/tsconfig.json +21 -21
  139. package/gui/README.md +73 -73
  140. package/gui/e2e/wizard.spec.ts +346 -346
  141. package/gui/eslint.config.js +26 -26
  142. package/gui/index.html +17 -17
  143. package/gui/package-lock.json +3079 -3079
  144. package/gui/package.json +36 -36
  145. package/gui/playwright.config.ts +41 -41
  146. package/gui/public/icons.svg +24 -24
  147. package/gui/src/App.css +1 -1
  148. package/gui/src/App.tsx +3632 -3407
  149. package/gui/src/assets/vite.svg +1 -1
  150. package/gui/src/features/changes/ChangesViewer.tsx +388 -388
  151. package/gui/src/features/knowledge/KnowledgeBase.tsx +84 -84
  152. package/gui/src/features/onboarding/OnboardingWizard.tsx +230 -230
  153. package/gui/src/features/plan/ImplementationPlan.tsx +32 -32
  154. package/gui/src/features/services/ServiceConsole.tsx +250 -250
  155. package/gui/src/features/sessions/SessionHistory.tsx +195 -195
  156. package/gui/src/features/workspace/WorkspaceList.tsx +666 -666
  157. package/gui/src/index.css +152 -152
  158. package/gui/src/main.tsx +94 -94
  159. package/gui/src/types.ts +62 -62
  160. package/gui/tsconfig.app.json +25 -25
  161. package/gui/tsconfig.json +7 -7
  162. package/gui/tsconfig.node.json +24 -24
  163. package/gui/vite.config.ts +12 -12
  164. package/package.json +55 -56
  165. package/resources/workflows/plan-implement-review.md +8 -8
  166. package/resources/workflows/research-verify.md +6 -6
  167. package/resources/workflows/solo-developer.md +3 -3
  168. package/scripts/simulate-workspaces.ts +55 -55
  169. package/skills-lock.json +11 -0
  170. package/src/analyzers/detect-apis.ts +290 -290
  171. package/src/analyzers/detect-deps.ts +315 -315
  172. package/src/analyzers/detect-existing.ts +74 -74
  173. package/src/analyzers/detect-ports.ts +110 -110
  174. package/src/analyzers/index.ts +97 -97
  175. package/src/analyzers/messaging-analyzer.ts +254 -254
  176. package/src/analyzers/readme-summarizer.ts +102 -102
  177. package/src/analyzers/run-analyzer.ts +269 -269
  178. package/src/analyzers/tech-stack.ts +283 -283
  179. package/src/commands/adapter.ts +348 -0
  180. package/src/commands/add-repo.ts +156 -157
  181. package/src/commands/commands.test.ts +156 -155
  182. package/src/commands/commit.ts +131 -131
  183. package/src/commands/config.ts +52 -0
  184. package/src/commands/create.ts +204 -207
  185. package/src/commands/desktop.ts +128 -128
  186. package/src/commands/diff.ts +125 -125
  187. package/src/commands/doctor.ts +370 -357
  188. package/src/commands/handoff.ts +266 -266
  189. package/src/commands/init.ts +134 -134
  190. package/src/commands/list.ts +46 -46
  191. package/src/commands/logs.ts +63 -63
  192. package/src/commands/mcp.ts +94 -92
  193. package/src/commands/open.ts +129 -129
  194. package/src/commands/refresh.ts +137 -147
  195. package/src/commands/remove.ts +98 -98
  196. package/src/commands/start.ts +117 -117
  197. package/src/commands/status.ts +54 -54
  198. package/src/commands/stop.ts +55 -55
  199. package/src/commands/sync.ts +147 -151
  200. package/src/commands/tui.ts +484 -424
  201. package/src/commands/ui.ts +111 -111
  202. package/src/core/adapters/local-storage.ts +66 -0
  203. package/src/core/adapters/obsidian-storage.ts +151 -0
  204. package/src/core/adapters/registry.ts +44 -0
  205. package/src/core/adapters/storage.test.ts +174 -0
  206. package/src/core/adapters/vault-storage.ts +86 -0
  207. package/src/core/config.test.ts +96 -96
  208. package/src/core/config.ts +140 -115
  209. package/src/core/graph.ts +344 -344
  210. package/src/core/plugins/index.ts +17 -0
  211. package/src/core/plugins/loader.ts +36 -0
  212. package/src/core/ports/storage.ts +71 -0
  213. package/src/core/scanner.test.ts +61 -61
  214. package/src/core/scanner.ts +91 -91
  215. package/src/core/storage.ts +37 -0
  216. package/src/core/workspace.ts +487 -380
  217. package/src/core/worktree.ts +120 -120
  218. package/src/generators/antigravity.ts +32 -32
  219. package/src/generators/base.ts +243 -240
  220. package/src/generators/claude.ts +34 -34
  221. package/src/generators/codex.ts +48 -48
  222. package/src/generators/copilot.ts +56 -56
  223. package/src/generators/cursor.ts +44 -44
  224. package/src/generators/diff-context.ts +75 -0
  225. package/src/generators/index.ts +322 -250
  226. package/src/generators/map-generator.test.ts +159 -159
  227. package/src/generators/map-generator.ts +539 -542
  228. package/src/generators/plan-generator.ts +483 -481
  229. package/src/generators/skills-generator.ts +259 -259
  230. package/src/index.ts +539 -426
  231. package/src/mcp/server.ts +294 -294
  232. package/src/orchestration/detect.ts +313 -313
  233. package/src/orchestration/index.ts +7 -7
  234. package/src/orchestration/runner.ts +283 -283
  235. package/src/server.test.ts +618 -555
  236. package/src/server.ts +1314 -1308
  237. package/src/types.ts +415 -408
  238. package/src/utils/detect-ai.test.ts +44 -44
  239. package/src/utils/detect-ai.ts +84 -84
  240. package/src/utils/detect-editors.test.ts +48 -48
  241. package/src/utils/detect-editors.ts +57 -57
  242. package/src/utils/git.test.ts +74 -74
  243. package/src/utils/git.ts +117 -117
  244. package/src/utils/local-ai.test.ts +130 -130
  245. package/src/utils/local-ai.ts +116 -111
  246. package/src/utils/multi-git.ts +313 -313
  247. package/src/utils/prompts.ts +209 -209
  248. package/src/utils/session-finder.ts +483 -483
  249. package/src/utils/system-scanner.test.ts +89 -89
  250. package/src/utils/system-scanner.ts +96 -96
  251. package/src/utils/update-check.ts +286 -309
  252. package/src/utils/workflow-advisor.ts +118 -0
  253. package/src/utils/workflows.test.ts +114 -114
  254. package/src/utils/workflows.ts +178 -178
  255. package/tsconfig.json +19 -19
  256. package/vitest.config.ts +14 -14
  257. package/.github/workflows/release.yml +0 -112
  258. package/dist/commands/pack.d.ts +0 -7
  259. package/dist/commands/pack.d.ts.map +0 -1
  260. package/dist/commands/pack.js +0 -64
  261. package/dist/commands/pack.js.map +0 -1
  262. package/dist/core/packer.d.ts +0 -14
  263. package/dist/core/packer.d.ts.map +0 -1
  264. package/dist/core/packer.js +0 -87
  265. package/dist/core/packer.js.map +0 -1
  266. package/dist/core/packer.test.d.ts +0 -2
  267. package/dist/core/packer.test.d.ts.map +0 -1
  268. package/dist/core/packer.test.js +0 -82
  269. package/dist/core/packer.test.js.map +0 -1
  270. package/dist/gui/assets/index-Ct3qq-X4.js +0 -25
  271. package/dist/gui/assets/index-P_ZrgHXg.css +0 -1
  272. package/src/commands/pack.ts +0 -73
  273. package/src/core/packer.test.ts +0 -99
  274. package/src/core/packer.ts +0 -107
@@ -1,555 +1,618 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import * as fs from 'node:fs/promises';
3
- import { execa } from 'execa';
4
- import { app } from './server.js';
5
- import * as workspace from './core/workspace.js';
6
- import * as config from './core/config.js';
7
- import * as systemScanner from './utils/system-scanner.js';
8
- import * as localAi from './utils/local-ai.js';
9
- import * as updateCheck from './utils/update-check.js';
10
- import * as analyzers from './analyzers/index.js';
11
- import * as generators from './generators/index.js';
12
- import * as packer from './core/packer.js';
13
- import * as workflows from './utils/workflows.js';
14
- import * as detectAi from './utils/detect-ai.js';
15
-
16
- // Mock dependencies
17
- vi.mock('node:fs/promises');
18
- vi.mock('execa');
19
- vi.mock('./core/workspace.js');
20
- vi.mock('./core/config.js');
21
- vi.mock('./utils/system-scanner.js');
22
- vi.mock('./utils/local-ai.js');
23
- vi.mock('./utils/update-check.js');
24
- vi.mock('./analyzers/index.js');
25
- vi.mock('./generators/index.js');
26
- vi.mock('./core/packer.js');
27
- vi.mock('./utils/workflows.js');
28
- vi.mock('./utils/detect-ai.js', () => ({
29
- detectAIAssistants: vi.fn().mockResolvedValue([])
30
- }));
31
-
32
- describe('Server API Endpoints Unit Tests', () => {
33
- beforeEach(() => {
34
- vi.clearAllMocks();
35
- });
36
-
37
- describe('POST /api/open-editor', () => {
38
- it('should return 400 for forbidden editor commands', async () => {
39
- const response = await app.request('/api/open-editor', {
40
- method: 'POST',
41
- headers: { 'Content-Type': 'application/json' },
42
- body: JSON.stringify({
43
- workspacePath: '/mock/workspace/path',
44
- command: 'rm -rf /'
45
- })
46
- });
47
-
48
- expect(response.status).toBe(400);
49
- const data = await response.json();
50
- expect(data.error).toBe('Forbidden editor command');
51
- });
52
-
53
- it('should return 400 if workspace path does not exist', async () => {
54
- vi.spyOn(fs, 'stat').mockRejectedValue(new Error('File not found'));
55
-
56
- const response = await app.request('/api/open-editor', {
57
- method: 'POST',
58
- headers: { 'Content-Type': 'application/json' },
59
- body: JSON.stringify({
60
- workspacePath: '/non-existent/path',
61
- command: 'code-insiders'
62
- })
63
- });
64
-
65
- expect(response.status).toBe(400);
66
- const data = await response.json();
67
- expect(data.error).toBe('Workspace path does not exist');
68
- });
69
-
70
- it('should return 400 if workspace path is not a directory', async () => {
71
- vi.spyOn(fs, 'stat').mockResolvedValue({
72
- isDirectory: () => false
73
- } as any);
74
-
75
- const response = await app.request('/api/open-editor', {
76
- method: 'POST',
77
- headers: { 'Content-Type': 'application/json' },
78
- body: JSON.stringify({
79
- workspacePath: '/mock/file.txt',
80
- command: 'code-insiders'
81
- })
82
- });
83
-
84
- expect(response.status).toBe(400);
85
- const data = await response.json();
86
- expect(data.error).toBe('Workspace path is not a directory');
87
- });
88
-
89
- it('should call execa with detached, stdio, shell, and cleanup options and return success', async () => {
90
- vi.spyOn(fs, 'stat').mockResolvedValue({
91
- isDirectory: () => true
92
- } as any);
93
-
94
- // Mock execa to return a dummy child process with a catch method
95
- const dummyChild = {
96
- unref: vi.fn(),
97
- catch: vi.fn().mockReturnThis()
98
- };
99
- vi.mocked(execa).mockReturnValue(dummyChild as any);
100
-
101
- const response = await app.request('/api/open-editor', {
102
- method: 'POST',
103
- headers: { 'Content-Type': 'application/json' },
104
- body: JSON.stringify({
105
- workspacePath: '/mock/workspace/dir',
106
- command: 'code-insiders'
107
- })
108
- });
109
-
110
- expect(response.status).toBe(200);
111
- const data = await response.json();
112
- expect(data.success).toBe(true);
113
-
114
- const isWin = process.platform === 'win32';
115
- expect(execa).toHaveBeenCalledWith('code-insiders', ['/mock/workspace/dir'], {
116
- detached: true,
117
- stdio: 'ignore',
118
- shell: isWin,
119
- cleanup: false
120
- });
121
- expect(dummyChild.unref).toHaveBeenCalled();
122
- expect(dummyChild.catch).toHaveBeenCalled();
123
- });
124
- });
125
-
126
- describe('POST /api/workspace/:id/resume', () => {
127
- it('should fail with 400 if command is forbidden', async () => {
128
- vi.spyOn(config, 'loadConfig').mockResolvedValue({
129
- workspacesDir: '/mock/workspaces'
130
- } as any);
131
- vi.spyOn(workspace, 'loadFeatureConfig').mockResolvedValue({
132
- id: 'test-ws',
133
- repos: [],
134
- assistants: ['antigravity']
135
- } as any);
136
-
137
- const response = await app.request('/api/workspace/test-ws/resume', {
138
- method: 'POST',
139
- headers: { 'Content-Type': 'application/json' },
140
- body: JSON.stringify({
141
- command: 'malicious-editor',
142
- assistant: 'antigravity'
143
- })
144
- });
145
-
146
- expect(response.status).toBe(400);
147
- const data = await response.json();
148
- expect(data.error).toBe('Forbidden editor command');
149
- });
150
-
151
- it('should spawn the editor with proper options when resuming', async () => {
152
- vi.spyOn(config, 'loadConfig').mockResolvedValue({
153
- workspacesDir: '/mock/workspaces'
154
- } as any);
155
- vi.spyOn(workspace, 'loadFeatureConfig').mockResolvedValue({
156
- id: 'test-ws',
157
- repos: [],
158
- assistants: ['antigravity']
159
- } as any);
160
-
161
- const dummyChild = {
162
- unref: vi.fn(),
163
- catch: vi.fn().mockReturnThis()
164
- };
165
- vi.mocked(execa).mockReturnValue(dummyChild as any);
166
-
167
- const response = await app.request('/api/workspace/test-ws/resume', {
168
- method: 'POST',
169
- headers: { 'Content-Type': 'application/json' },
170
- body: JSON.stringify({
171
- command: 'code',
172
- assistant: 'antigravity'
173
- })
174
- });
175
-
176
- expect(response.status).toBe(200);
177
- const data = await response.json();
178
- expect(data.success).toBe(true);
179
-
180
- const isWin = process.platform === 'win32';
181
- expect(execa).toHaveBeenCalledWith('code', [expect.any(String)], {
182
- detached: true,
183
- stdio: 'ignore',
184
- shell: isWin,
185
- cleanup: false
186
- });
187
- expect(dummyChild.unref).toHaveBeenCalled();
188
- expect(dummyChild.catch).toHaveBeenCalled();
189
- });
190
- });
191
-
192
- describe('GET /api/config', () => {
193
- it('should load configuration successfully', async () => {
194
- vi.spyOn(config, 'getConfigDir').mockReturnValue('/mock/config-dir');
195
- vi.spyOn(config, 'loadConfig').mockResolvedValue({
196
- devDir: '/mock/dev',
197
- scanDepth: 2
198
- } as any);
199
- vi.spyOn(fs, 'access').mockResolvedValue();
200
-
201
- const response = await app.request('/api/config');
202
- expect(response.status).toBe(200);
203
- const data = await response.json();
204
- expect(data.config.devDir).toBe('/mock/dev');
205
- expect(data.exists).toBe(true);
206
- });
207
- });
208
-
209
- describe('POST /api/config', () => {
210
- it('should validate endpoint domain safety', async () => {
211
- const response = await app.request('/api/config', {
212
- method: 'POST',
213
- headers: { 'Content-Type': 'application/json' },
214
- body: JSON.stringify({
215
- localLlm: {
216
- enabled: true,
217
- endpoint: 'http://malicious-external-domain.com'
218
- }
219
- })
220
- });
221
-
222
- expect(response.status).toBe(400);
223
- const data = await response.json();
224
- expect(data.error).toContain('Local AI endpoint must be localhost, 127.0.0.1, or a private LAN IP.');
225
- });
226
-
227
- it('should save safe endpoint and config', async () => {
228
- vi.spyOn(config, 'saveConfig').mockResolvedValue();
229
-
230
- const response = await app.request('/api/config', {
231
- method: 'POST',
232
- headers: { 'Content-Type': 'application/json' },
233
- body: JSON.stringify({
234
- localLlm: {
235
- enabled: true,
236
- endpoint: 'http://127.0.0.1:11434'
237
- }
238
- })
239
- });
240
-
241
- expect(response.status).toBe(200);
242
- const data = await response.json();
243
- expect(data.success).toBe(true);
244
- });
245
- });
246
-
247
- describe('POST /api/local-llm/test', () => {
248
- it('should reject unsafe endpoints', async () => {
249
- const response = await app.request('/api/local-llm/test', {
250
- method: 'POST',
251
- headers: { 'Content-Type': 'application/json' },
252
- body: JSON.stringify({
253
- endpoint: 'http://unsafe-domain.com',
254
- provider: 'ollama',
255
- model: 'qwen2.5-coder:1.5b'
256
- })
257
- });
258
-
259
- expect(response.status).toBe(400);
260
- const data = await response.json();
261
- expect(data.success).toBe(false);
262
- expect(data.error).toContain('Local AI endpoint must be');
263
- });
264
-
265
- it('should perform inference shoot test if shoot = true', async () => {
266
- vi.spyOn(localAi, 'callLocalLlm').mockResolvedValue('OK\n');
267
-
268
- const response = await app.request('/api/local-llm/test', {
269
- method: 'POST',
270
- headers: { 'Content-Type': 'application/json' },
271
- body: JSON.stringify({
272
- endpoint: 'http://localhost:11434',
273
- provider: 'ollama',
274
- model: 'qwen2.5-coder:1.5b',
275
- shoot: true
276
- })
277
- });
278
-
279
- expect(response.status).toBe(200);
280
- const data = await response.json();
281
- expect(data.success).toBe(true);
282
- expect(data.modelReady).toBe(true);
283
- expect(data.message).toContain('Inference test succeeded');
284
- });
285
- });
286
-
287
- describe('GET /api/local-llm/recommend', () => {
288
- it('should fetch system specs and return them', async () => {
289
- vi.spyOn(systemScanner, 'scanSystemSpecs').mockResolvedValue({
290
- totalRamGb: 16,
291
- gpuName: 'Nvidia RTX 4080',
292
- hasHardwareAcceleration: true,
293
- recommendedModel: 'qwen2.5-coder:7b'
294
- });
295
-
296
- const response = await app.request('/api/local-llm/recommend');
297
- expect(response.status).toBe(200);
298
- const data = await response.json();
299
- expect(data.totalRamGb).toBe(16);
300
- expect(data.gpuName).toBe('Nvidia RTX 4080');
301
- expect(data.recommendedModel).toBe('qwen2.5-coder:7b');
302
- });
303
- });
304
-
305
- describe('GET and PUT /api/workspace/:id/knowledge', () => {
306
- it('should get workspace knowledge content', async () => {
307
- vi.spyOn(config, 'loadConfig').mockResolvedValue({
308
- workspacesDir: '/mock/workspaces'
309
- } as any);
310
- vi.spyOn(fs, 'readFile').mockResolvedValue('# Mock Knowledge File content');
311
-
312
- const response = await app.request('/api/workspace/test-ws/knowledge');
313
- expect(response.status).toBe(200);
314
- const data = await response.json();
315
- expect(data.content).toBe('# Mock Knowledge File content');
316
- });
317
-
318
- it('should write workspace knowledge content', async () => {
319
- vi.spyOn(config, 'loadConfig').mockResolvedValue({
320
- workspacesDir: '/mock/workspaces'
321
- } as any);
322
- vi.spyOn(fs, 'writeFile').mockResolvedValue();
323
-
324
- const response = await app.request('/api/workspace/test-ws/knowledge', {
325
- method: 'PUT',
326
- headers: { 'Content-Type': 'application/json' },
327
- body: JSON.stringify({ content: '# Updated Content' })
328
- });
329
-
330
- expect(response.status).toBe(200);
331
- const data = await response.json();
332
- expect(data.success).toBe(true);
333
- });
334
- });
335
-
336
- describe('GET /api/workspace/:id/plan', () => {
337
- it('should get workspace plan content', async () => {
338
- vi.spyOn(config, 'loadConfig').mockResolvedValue({
339
- workspacesDir: '/mock/workspaces'
340
- } as any);
341
- vi.spyOn(fs, 'readFile').mockResolvedValue('# Mock Plan content');
342
-
343
- const response = await app.request('/api/workspace/test-ws/plan');
344
- expect(response.status).toBe(200);
345
- const data = await response.json();
346
- expect(data.content).toBe('# Mock Plan content');
347
- });
348
- });
349
-
350
- describe('POST /api/updates/install', () => {
351
- it('should fail if tool not found', async () => {
352
- const response = await app.request('/api/updates/install', {
353
- method: 'POST',
354
- headers: { 'Content-Type': 'application/json' },
355
- body: JSON.stringify({ toolId: 'invalid-tool' })
356
- });
357
-
358
- expect(response.status).toBe(404);
359
- const data = await response.json();
360
- expect(data.error).toBe('Tool not found');
361
- });
362
-
363
- it('should install tool successfully if execa exits with 0', async () => {
364
- vi.mocked(execa).mockResolvedValue({ exitCode: 0, stdout: 'Successfully updated' } as any);
365
-
366
- const response = await app.request('/api/updates/install', {
367
- method: 'POST',
368
- headers: { 'Content-Type': 'application/json' },
369
- body: JSON.stringify({ toolId: 'repomix' })
370
- });
371
-
372
- expect(response.status).toBe(200);
373
- const data = await response.json();
374
- expect(data.success).toBe(true);
375
- expect(data.output).toBe('Successfully updated');
376
- expect(execa).toHaveBeenCalledWith('npm', ['install', '-g', 'repomix'], expect.any(Object));
377
- });
378
- });
379
-
380
- describe('POST /api/workspace', () => {
381
- it('should complete workspace creation successfully when packContextXml is false', async () => {
382
- vi.spyOn(config, 'loadConfig').mockResolvedValue({
383
- workspacesDir: '/mock/workspaces',
384
- packContextXml: false
385
- } as any);
386
-
387
- vi.spyOn(workspace, 'createWorkspace').mockResolvedValue('/mock/workspaces/test-ws-creation-no-pack');
388
- vi.spyOn(analyzers, 'analyzeAllRepos').mockResolvedValue(new Map());
389
- vi.spyOn(generators, 'generateContextFiles').mockResolvedValue(undefined);
390
-
391
- const response = await app.request('/api/workspace', {
392
- method: 'POST',
393
- headers: { 'Content-Type': 'application/json' },
394
- body: JSON.stringify({
395
- branchName: 'test-ws-creation-no-pack',
396
- description: 'A test workspace',
397
- repos: [{ name: 'repo-1', path: '/mock/repo-1' }],
398
- assistants: ['antigravity']
399
- })
400
- });
401
-
402
- expect(response.status).toBe(200);
403
- const data = await response.json();
404
- expect(data.success).toBe(true);
405
- expect(data.jobId).toBe('test-ws-creation-no-pack');
406
-
407
- // Wait a brief tick for the background job to execute
408
- await new Promise((resolve) => setTimeout(resolve, 50));
409
-
410
- expect(workspace.createWorkspace).toHaveBeenCalled();
411
- expect(analyzers.analyzeAllRepos).toHaveBeenCalled();
412
- expect(generators.generateContextFiles).toHaveBeenCalled();
413
-
414
- // Read status via SSE stream route
415
- const streamResponse = await app.request('/api/workspace/create-stream/test-ws-creation-no-pack');
416
- expect(streamResponse.status).toBe(200);
417
- const text = await streamResponse.text();
418
- expect(text).toContain('"status":"completed"');
419
- expect(text).toContain('"progress":100');
420
- });
421
-
422
- it('should complete workspace creation successfully when packContextXml is true', async () => {
423
- vi.spyOn(config, 'loadConfig').mockResolvedValue({
424
- workspacesDir: '/mock/workspaces',
425
- packContextXml: true
426
- } as any);
427
-
428
- vi.spyOn(workspace, 'createWorkspace').mockResolvedValue('/mock/workspaces/test-ws-creation-with-pack');
429
- vi.spyOn(analyzers, 'analyzeAllRepos').mockResolvedValue(new Map());
430
- vi.spyOn(generators, 'generateContextFiles').mockResolvedValue(undefined);
431
- vi.spyOn(packer, 'packWorkspace').mockResolvedValue({
432
- outputPath: '/mock/workspace/nexusflow-context.xml',
433
- totalFiles: 5,
434
- totalCharacters: 1000,
435
- fileSize: 5120
436
- });
437
-
438
- const response = await app.request('/api/workspace', {
439
- method: 'POST',
440
- headers: { 'Content-Type': 'application/json' },
441
- body: JSON.stringify({
442
- branchName: 'test-ws-creation-with-pack',
443
- description: 'A test workspace',
444
- repos: [{ name: 'repo-1', path: '/mock/repo-1' }],
445
- assistants: ['antigravity']
446
- })
447
- });
448
-
449
- expect(response.status).toBe(200);
450
- const data = await response.json();
451
- expect(data.success).toBe(true);
452
- expect(data.jobId).toBe('test-ws-creation-with-pack');
453
-
454
- // Wait a brief tick for the background job to execute
455
- await new Promise((resolve) => setTimeout(resolve, 50));
456
-
457
- expect(workspace.createWorkspace).toHaveBeenCalled();
458
- expect(analyzers.analyzeAllRepos).toHaveBeenCalled();
459
- expect(generators.generateContextFiles).toHaveBeenCalled();
460
- expect(packer.packWorkspace).toHaveBeenCalled();
461
-
462
- // Read status via SSE stream route
463
- const streamResponse = await app.request('/api/workspace/create-stream/test-ws-creation-with-pack');
464
- expect(streamResponse.status).toBe(200);
465
- const text = await streamResponse.text();
466
- expect(text).toContain('"status":"completed"');
467
- expect(text).toContain('"progress":100');
468
- });
469
- });
470
-
471
- describe('Workflows Templates API', () => {
472
- it('GET /api/workflows/templates should return list of templates', async () => {
473
- vi.spyOn(workflows, 'getWorkflowTemplates').mockResolvedValue([
474
- { id: 'test-id', name: 'Test Name', description: 'Test Desc', content: 'Test Content', custom: false }
475
- ]);
476
-
477
- const response = await app.request('/api/workflows/templates');
478
- expect(response.status).toBe(200);
479
- const data = await response.json();
480
- expect(data).toEqual({
481
- templates: [
482
- { id: 'test-id', name: 'Test Name', description: 'Test Desc', content: 'Test Content', custom: false }
483
- ]
484
- });
485
- });
486
-
487
- it('POST /api/workflows/templates should create or update template', async () => {
488
- vi.spyOn(workflows, 'saveWorkflowTemplate').mockResolvedValue({
489
- id: 'test-id',
490
- name: 'Test Name',
491
- description: 'Test Desc',
492
- content: 'Test Content',
493
- custom: true
494
- });
495
-
496
- const response = await app.request('/api/workflows/templates', {
497
- method: 'POST',
498
- headers: { 'Content-Type': 'application/json' },
499
- body: JSON.stringify({ id: 'old-id', name: 'Test Name', content: 'Test Content' })
500
- });
501
-
502
- expect(response.status).toBe(200);
503
- const data = await response.json();
504
- expect(data.success).toBe(true);
505
- expect(data.template.id).toBe('test-id');
506
- expect(workflows.saveWorkflowTemplate).toHaveBeenCalledWith('Test Name', 'Test Content', 'old-id');
507
- });
508
-
509
- it('DELETE /api/workflows/templates/:id should delete template', async () => {
510
- vi.spyOn(workflows, 'getWorkflowTemplates').mockResolvedValue([
511
- { id: 'test-id', name: 'Test Name', description: 'Test Desc', content: 'Test Content', custom: true }
512
- ]);
513
- vi.spyOn(workflows, 'deleteWorkflowTemplate').mockResolvedValue(undefined);
514
-
515
- const response = await app.request('/api/workflows/templates/test-id', {
516
- method: 'DELETE'
517
- });
518
-
519
- expect(response.status).toBe(200);
520
- const data = await response.json();
521
- expect(data.success).toBe(true);
522
- expect(workflows.deleteWorkflowTemplate).toHaveBeenCalledWith('test-id');
523
- });
524
-
525
- it('POST /api/workflows/templates/:id/analyze should run inspection with comment', async () => {
526
- vi.mocked(detectAi.detectAIAssistants).mockResolvedValue([
527
- { name: 'antigravity', displayName: 'Antigravity', detected: true, command: 'agy' }
528
- ]);
529
-
530
- vi.mocked(execa).mockResolvedValue({
531
- exitCode: 0,
532
- stdout: 'Review result: Success\n=== SUGGESTED IMPROVEMENT START ===\n# Refined Strategy\n=== SUGGESTED IMPROVEMENT END ==='
533
- } as any);
534
-
535
- const response = await app.request('/api/workflows/templates/test-id/analyze', {
536
- method: 'POST',
537
- headers: { 'Content-Type': 'application/json' },
538
- body: JSON.stringify({
539
- content: 'My Strategy guidelines',
540
- assistant: 'antigravity',
541
- comment: 'Check for timeouts'
542
- })
543
- });
544
-
545
- expect(response.status).toBe(200);
546
- const data = await response.json();
547
- expect(data.analysis).toContain('Review result: Success');
548
- expect(data.suggestedImprovement).toBe('# Refined Strategy');
549
- expect(execa).toHaveBeenCalled();
550
-
551
- const calledArgs = vi.mocked(execa).mock.calls[0][1];
552
- expect(JSON.stringify(calledArgs)).toContain('Check for timeouts');
553
- });
554
- });
555
- });
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import * as fs from 'node:fs/promises';
3
+ import { execa } from 'execa';
4
+ import { app } from './server.js';
5
+ import * as workspace from './core/workspace.js';
6
+ import * as config from './core/config.js';
7
+ import * as systemScanner from './utils/system-scanner.js';
8
+ import * as localAi from './utils/local-ai.js';
9
+ import * as updateCheck from './utils/update-check.js';
10
+ import * as analyzers from './analyzers/index.js';
11
+ import * as generators from './generators/index.js';
12
+ import * as workflows from './utils/workflows.js';
13
+ import * as detectAi from './utils/detect-ai.js';
14
+
15
+ // Mock dependencies
16
+ vi.mock('node:fs/promises');
17
+ vi.mock('execa');
18
+ vi.mock('./core/workspace.js');
19
+ vi.mock('./core/config.js');
20
+ vi.mock('./utils/system-scanner.js');
21
+ vi.mock('./utils/local-ai.js');
22
+ vi.mock('./utils/update-check.js');
23
+ vi.mock('./analyzers/index.js');
24
+ vi.mock('./generators/index.js');
25
+ vi.mock('./utils/workflows.js');
26
+ vi.mock('./utils/detect-ai.js', () => ({
27
+ detectAIAssistants: vi.fn().mockResolvedValue([])
28
+ }));
29
+
30
+ describe('Server API Endpoints Unit Tests', () => {
31
+ beforeEach(() => {
32
+ vi.clearAllMocks();
33
+ });
34
+
35
+ describe('POST /api/open-editor', () => {
36
+ it('should return 400 for forbidden editor commands', async () => {
37
+ const response = await app.request('/api/open-editor', {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify({
41
+ workspacePath: '/mock/workspace/path',
42
+ command: 'rm -rf /'
43
+ })
44
+ });
45
+
46
+ expect(response.status).toBe(400);
47
+ const data = await response.json();
48
+ expect(data.error).toBe('Forbidden editor command');
49
+ });
50
+
51
+ it('should return 400 if workspace path does not exist', async () => {
52
+ vi.spyOn(fs, 'stat').mockRejectedValue(new Error('File not found'));
53
+
54
+ const response = await app.request('/api/open-editor', {
55
+ method: 'POST',
56
+ headers: { 'Content-Type': 'application/json' },
57
+ body: JSON.stringify({
58
+ workspacePath: '/non-existent/path',
59
+ command: 'code-insiders'
60
+ })
61
+ });
62
+
63
+ expect(response.status).toBe(400);
64
+ const data = await response.json();
65
+ expect(data.error).toBe('Workspace path does not exist');
66
+ });
67
+
68
+ it('should return 400 if workspace path is not a directory', async () => {
69
+ vi.spyOn(fs, 'stat').mockResolvedValue({
70
+ isDirectory: () => false
71
+ } as any);
72
+
73
+ const response = await app.request('/api/open-editor', {
74
+ method: 'POST',
75
+ headers: { 'Content-Type': 'application/json' },
76
+ body: JSON.stringify({
77
+ workspacePath: '/mock/file.txt',
78
+ command: 'code-insiders'
79
+ })
80
+ });
81
+
82
+ expect(response.status).toBe(400);
83
+ const data = await response.json();
84
+ expect(data.error).toBe('Workspace path is not a directory');
85
+ });
86
+
87
+ it('should call execa with detached, stdio, shell, and cleanup options and return success', async () => {
88
+ vi.spyOn(fs, 'stat').mockResolvedValue({
89
+ isDirectory: () => true
90
+ } as any);
91
+
92
+ // Mock execa to return a dummy child process with a catch method
93
+ const dummyChild = {
94
+ unref: vi.fn(),
95
+ catch: vi.fn().mockReturnThis()
96
+ };
97
+ vi.mocked(execa).mockReturnValue(dummyChild as any);
98
+
99
+ const response = await app.request('/api/open-editor', {
100
+ method: 'POST',
101
+ headers: { 'Content-Type': 'application/json' },
102
+ body: JSON.stringify({
103
+ workspacePath: '/mock/workspace/dir',
104
+ command: 'code-insiders'
105
+ })
106
+ });
107
+
108
+ expect(response.status).toBe(200);
109
+ const data = await response.json();
110
+ expect(data.success).toBe(true);
111
+
112
+ const isWin = process.platform === 'win32';
113
+ expect(execa).toHaveBeenCalledWith('code-insiders', ['/mock/workspace/dir'], {
114
+ detached: true,
115
+ stdio: 'ignore',
116
+ shell: isWin,
117
+ cleanup: false
118
+ });
119
+ expect(dummyChild.unref).toHaveBeenCalled();
120
+ expect(dummyChild.catch).toHaveBeenCalled();
121
+ });
122
+ });
123
+
124
+ describe('POST /api/workspace/:id/resume', () => {
125
+ it('should fail with 400 if command is forbidden', async () => {
126
+ vi.spyOn(config, 'loadConfig').mockResolvedValue({
127
+ workspacesDir: '/mock/workspaces'
128
+ } as any);
129
+ vi.spyOn(workspace, 'loadFeatureConfig').mockResolvedValue({
130
+ id: 'test-ws',
131
+ repos: [],
132
+ assistants: ['antigravity']
133
+ } as any);
134
+
135
+ const response = await app.request('/api/workspace/test-ws/resume', {
136
+ method: 'POST',
137
+ headers: { 'Content-Type': 'application/json' },
138
+ body: JSON.stringify({
139
+ command: 'malicious-editor',
140
+ assistant: 'antigravity'
141
+ })
142
+ });
143
+
144
+ expect(response.status).toBe(400);
145
+ const data = await response.json();
146
+ expect(data.error).toBe('Forbidden editor command');
147
+ });
148
+
149
+ it('should spawn the editor with proper options when resuming', async () => {
150
+ vi.spyOn(config, 'loadConfig').mockResolvedValue({
151
+ workspacesDir: '/mock/workspaces'
152
+ } as any);
153
+ vi.spyOn(workspace, 'loadFeatureConfig').mockResolvedValue({
154
+ id: 'test-ws',
155
+ repos: [],
156
+ assistants: ['antigravity']
157
+ } as any);
158
+
159
+ const dummyChild = {
160
+ unref: vi.fn(),
161
+ catch: vi.fn().mockReturnThis()
162
+ };
163
+ vi.mocked(execa).mockReturnValue(dummyChild as any);
164
+
165
+ const response = await app.request('/api/workspace/test-ws/resume', {
166
+ method: 'POST',
167
+ headers: { 'Content-Type': 'application/json' },
168
+ body: JSON.stringify({
169
+ command: 'code',
170
+ assistant: 'antigravity'
171
+ })
172
+ });
173
+
174
+ expect(response.status).toBe(200);
175
+ const data = await response.json();
176
+ expect(data.success).toBe(true);
177
+
178
+ const isWin = process.platform === 'win32';
179
+ expect(execa).toHaveBeenCalledWith('code', [expect.any(String)], {
180
+ detached: true,
181
+ stdio: 'ignore',
182
+ shell: isWin,
183
+ cleanup: false
184
+ });
185
+ expect(dummyChild.unref).toHaveBeenCalled();
186
+ expect(dummyChild.catch).toHaveBeenCalled();
187
+ });
188
+ });
189
+
190
+ describe('GET /api/config', () => {
191
+ it('should load configuration successfully', async () => {
192
+ vi.spyOn(config, 'getConfigDir').mockReturnValue('/mock/config-dir');
193
+ vi.spyOn(config, 'loadConfig').mockResolvedValue({
194
+ devDir: '/mock/dev',
195
+ scanDepth: 2
196
+ } as any);
197
+ vi.spyOn(fs, 'access').mockResolvedValue();
198
+
199
+ const response = await app.request('/api/config');
200
+ expect(response.status).toBe(200);
201
+ const data = await response.json();
202
+ expect(data.config.devDir).toBe('/mock/dev');
203
+ expect(data.exists).toBe(true);
204
+ });
205
+ });
206
+
207
+ describe('GET /api/adapters', () => {
208
+ it('should return all registered storage adapters with meta', async () => {
209
+ const response = await app.request('/api/adapters');
210
+ expect(response.status).toBe(200);
211
+ const data = await response.json();
212
+ expect(data.adapters).toBeDefined();
213
+ expect(Array.isArray(data.adapters)).toBe(true);
214
+ // It should include at least 'local', 'central-vault', and 'obsidian'
215
+ const names = data.adapters.map((a: any) => a.name);
216
+ expect(names).toContain('local');
217
+ expect(names).toContain('central-vault');
218
+ expect(names).toContain('obsidian');
219
+
220
+ const obsidian = data.adapters.find((a: any) => a.name === 'obsidian');
221
+ expect(obsidian.displayName).toBe('Obsidian Vault');
222
+ expect(obsidian.configFields.length).toBeGreaterThan(0);
223
+ });
224
+ });
225
+
226
+ describe('POST /api/config', () => {
227
+ it('should validate endpoint domain safety', async () => {
228
+ const response = await app.request('/api/config', {
229
+ method: 'POST',
230
+ headers: { 'Content-Type': 'application/json' },
231
+ body: JSON.stringify({
232
+ localLlm: {
233
+ enabled: true,
234
+ endpoint: 'http://malicious-external-domain.com'
235
+ }
236
+ })
237
+ });
238
+
239
+ expect(response.status).toBe(400);
240
+ const data = await response.json();
241
+ expect(data.error).toContain('Local AI endpoint must be HTTPS, localhost, 127.0.0.1, or a private LAN IP.');
242
+ });
243
+
244
+ it('should save safe endpoint and config', async () => {
245
+ vi.spyOn(config, 'saveConfig').mockResolvedValue();
246
+
247
+ const response = await app.request('/api/config', {
248
+ method: 'POST',
249
+ headers: { 'Content-Type': 'application/json' },
250
+ body: JSON.stringify({
251
+ localLlm: {
252
+ enabled: true,
253
+ endpoint: 'http://127.0.0.1:11434'
254
+ }
255
+ })
256
+ });
257
+
258
+ expect(response.status).toBe(200);
259
+ const data = await response.json();
260
+ expect(data.success).toBe(true);
261
+ });
262
+ });
263
+
264
+ describe('POST /api/local-llm/test', () => {
265
+ it('should reject unsafe endpoints', async () => {
266
+ const response = await app.request('/api/local-llm/test', {
267
+ method: 'POST',
268
+ headers: { 'Content-Type': 'application/json' },
269
+ body: JSON.stringify({
270
+ endpoint: 'http://unsafe-domain.com',
271
+ provider: 'ollama',
272
+ model: 'qwen2.5-coder:1.5b'
273
+ })
274
+ });
275
+
276
+ expect(response.status).toBe(400);
277
+ const data = await response.json();
278
+ expect(data.success).toBe(false);
279
+ expect(data.error).toContain('Local AI endpoint must be');
280
+ });
281
+
282
+ it('should perform inference shoot test if shoot = true', async () => {
283
+ vi.spyOn(localAi, 'callLocalLlm').mockResolvedValue('OK\n');
284
+
285
+ const response = await app.request('/api/local-llm/test', {
286
+ method: 'POST',
287
+ headers: { 'Content-Type': 'application/json' },
288
+ body: JSON.stringify({
289
+ endpoint: 'http://localhost:11434',
290
+ provider: 'ollama',
291
+ model: 'qwen2.5-coder:1.5b',
292
+ shoot: true
293
+ })
294
+ });
295
+
296
+ expect(response.status).toBe(200);
297
+ const data = await response.json();
298
+ expect(data.success).toBe(true);
299
+ expect(data.modelReady).toBe(true);
300
+ expect(data.message).toContain('Inference test succeeded');
301
+ });
302
+ });
303
+
304
+ describe('GET /api/local-llm/recommend', () => {
305
+ it('should fetch system specs and return them', async () => {
306
+ vi.spyOn(systemScanner, 'scanSystemSpecs').mockResolvedValue({
307
+ totalRamGb: 16,
308
+ gpuName: 'Nvidia RTX 4080',
309
+ hasHardwareAcceleration: true,
310
+ recommendedModel: 'qwen2.5-coder:7b'
311
+ });
312
+
313
+ const response = await app.request('/api/local-llm/recommend');
314
+ expect(response.status).toBe(200);
315
+ const data = await response.json();
316
+ expect(data.totalRamGb).toBe(16);
317
+ expect(data.gpuName).toBe('Nvidia RTX 4080');
318
+ expect(data.recommendedModel).toBe('qwen2.5-coder:7b');
319
+ });
320
+ });
321
+
322
+ describe('GET and PUT /api/workspace/:id/knowledge', () => {
323
+ it('should get workspace knowledge content', async () => {
324
+ vi.spyOn(config, 'loadConfig').mockResolvedValue({
325
+ workspacesDir: '/mock/workspaces'
326
+ } as any);
327
+ vi.spyOn(fs, 'readFile').mockResolvedValue('# Mock Knowledge File content');
328
+
329
+ const response = await app.request('/api/workspace/test-ws/knowledge');
330
+ expect(response.status).toBe(200);
331
+ const data = await response.json();
332
+ expect(data.content).toBe('# Mock Knowledge File content');
333
+ });
334
+
335
+ it('should write workspace knowledge content', async () => {
336
+ vi.spyOn(config, 'loadConfig').mockResolvedValue({
337
+ workspacesDir: '/mock/workspaces'
338
+ } as any);
339
+ vi.spyOn(fs, 'writeFile').mockResolvedValue();
340
+
341
+ const response = await app.request('/api/workspace/test-ws/knowledge', {
342
+ method: 'PUT',
343
+ headers: { 'Content-Type': 'application/json' },
344
+ body: JSON.stringify({ content: '# Updated Content' })
345
+ });
346
+
347
+ expect(response.status).toBe(200);
348
+ const data = await response.json();
349
+ expect(data.success).toBe(true);
350
+ });
351
+ });
352
+
353
+ describe('GET /api/workspace/:id/plan', () => {
354
+ it('should get workspace plan content', async () => {
355
+ vi.spyOn(config, 'loadConfig').mockResolvedValue({
356
+ workspacesDir: '/mock/workspaces'
357
+ } as any);
358
+ vi.spyOn(fs, 'readFile').mockResolvedValue('# Mock Plan content');
359
+
360
+ const response = await app.request('/api/workspace/test-ws/plan');
361
+ expect(response.status).toBe(200);
362
+ const data = await response.json();
363
+ expect(data.content).toBe('# Mock Plan content');
364
+ });
365
+ });
366
+
367
+ describe('POST /api/updates/install', () => {
368
+ it('should fail if tool not found', async () => {
369
+ const response = await app.request('/api/updates/install', {
370
+ method: 'POST',
371
+ headers: { 'Content-Type': 'application/json' },
372
+ body: JSON.stringify({ toolId: 'invalid-tool' })
373
+ });
374
+
375
+ expect(response.status).toBe(404);
376
+ const data = await response.json();
377
+ expect(data.error).toBe('Tool not found');
378
+ });
379
+
380
+ it('should install tool successfully if execa exits with 0', async () => {
381
+ vi.mocked(execa).mockResolvedValue({ exitCode: 0, stdout: 'Successfully updated' } as any);
382
+
383
+ const response = await app.request('/api/updates/install', {
384
+ method: 'POST',
385
+ headers: { 'Content-Type': 'application/json' },
386
+ body: JSON.stringify({ toolId: 'nexusflow' })
387
+ });
388
+
389
+ expect(response.status).toBe(200);
390
+ const data = await response.json();
391
+ expect(data.success).toBe(true);
392
+ expect(data.output).toBe('Successfully updated');
393
+ expect(execa).toHaveBeenCalledWith('npm', ['install', '-g', '@mrpatronz/nexusflow'], expect.any(Object));
394
+ });
395
+ });
396
+
397
+ describe('POST /api/workspace', () => {
398
+ it('should complete workspace creation successfully', async () => {
399
+ vi.spyOn(config, 'loadConfig').mockResolvedValue({
400
+ workspacesDir: '/mock/workspaces',
401
+ storageProvider: 'local'
402
+ } as any);
403
+
404
+ vi.spyOn(workspace, 'createWorkspace').mockResolvedValue('/mock/workspaces/test-ws-creation-no-pack');
405
+ vi.spyOn(analyzers, 'analyzeAllRepos').mockResolvedValue(new Map());
406
+ vi.spyOn(generators, 'generateContextFiles').mockResolvedValue(undefined);
407
+
408
+ const response = await app.request('/api/workspace', {
409
+ method: 'POST',
410
+ headers: { 'Content-Type': 'application/json' },
411
+ body: JSON.stringify({
412
+ branchName: 'test-ws-creation-no-pack',
413
+ description: 'A test workspace',
414
+ repos: [{ name: 'repo-1', path: '/mock/repo-1' }],
415
+ assistants: ['antigravity']
416
+ })
417
+ });
418
+
419
+ expect(response.status).toBe(200);
420
+ const data = await response.json();
421
+ expect(data.success).toBe(true);
422
+ expect(data.jobId).toBe('test-ws-creation-no-pack');
423
+
424
+ // Wait a brief tick for the background job to execute
425
+ await new Promise((resolve) => setTimeout(resolve, 50));
426
+
427
+ expect(workspace.createWorkspace).toHaveBeenCalled();
428
+ expect(analyzers.analyzeAllRepos).toHaveBeenCalled();
429
+ expect(generators.generateContextFiles).toHaveBeenCalled();
430
+
431
+ // Read status via SSE stream route
432
+ const streamResponse = await app.request('/api/workspace/create-stream/test-ws-creation-no-pack');
433
+ expect(streamResponse.status).toBe(200);
434
+ const text = await streamResponse.text();
435
+ expect(text).toContain('"status":"completed"');
436
+ expect(text).toContain('"progress":100');
437
+ });
438
+
439
+ // XML context packing tests removed.
440
+ });
441
+
442
+ describe('Workflows Templates API', () => {
443
+ it('GET /api/workflows/templates should return list of templates', async () => {
444
+ vi.spyOn(workflows, 'getWorkflowTemplates').mockResolvedValue([
445
+ { id: 'test-id', name: 'Test Name', description: 'Test Desc', content: 'Test Content', custom: false }
446
+ ]);
447
+
448
+ const response = await app.request('/api/workflows/templates');
449
+ expect(response.status).toBe(200);
450
+ const data = await response.json();
451
+ expect(data).toEqual({
452
+ templates: [
453
+ { id: 'test-id', name: 'Test Name', description: 'Test Desc', content: 'Test Content', custom: false }
454
+ ]
455
+ });
456
+ });
457
+
458
+ it('POST /api/workflows/templates should create or update template', async () => {
459
+ vi.spyOn(workflows, 'saveWorkflowTemplate').mockResolvedValue({
460
+ id: 'test-id',
461
+ name: 'Test Name',
462
+ description: 'Test Desc',
463
+ content: 'Test Content',
464
+ custom: true
465
+ });
466
+
467
+ const response = await app.request('/api/workflows/templates', {
468
+ method: 'POST',
469
+ headers: { 'Content-Type': 'application/json' },
470
+ body: JSON.stringify({ id: 'old-id', name: 'Test Name', content: 'Test Content' })
471
+ });
472
+
473
+ expect(response.status).toBe(200);
474
+ const data = await response.json();
475
+ expect(data.success).toBe(true);
476
+ expect(data.template.id).toBe('test-id');
477
+ expect(workflows.saveWorkflowTemplate).toHaveBeenCalledWith('Test Name', 'Test Content', 'old-id');
478
+ });
479
+
480
+ it('DELETE /api/workflows/templates/:id should delete template', async () => {
481
+ vi.spyOn(workflows, 'getWorkflowTemplates').mockResolvedValue([
482
+ { id: 'test-id', name: 'Test Name', description: 'Test Desc', content: 'Test Content', custom: true }
483
+ ]);
484
+ vi.spyOn(workflows, 'deleteWorkflowTemplate').mockResolvedValue(undefined);
485
+
486
+ const response = await app.request('/api/workflows/templates/test-id', {
487
+ method: 'DELETE'
488
+ });
489
+
490
+ expect(response.status).toBe(200);
491
+ const data = await response.json();
492
+ expect(data.success).toBe(true);
493
+ expect(workflows.deleteWorkflowTemplate).toHaveBeenCalledWith('test-id');
494
+ });
495
+
496
+ it('POST /api/workflows/templates/:id/analyze should run inspection with comment', async () => {
497
+ vi.mocked(detectAi.detectAIAssistants).mockResolvedValue([
498
+ { name: 'antigravity', displayName: 'Antigravity', detected: true, command: 'agy' }
499
+ ]);
500
+
501
+ vi.mocked(execa).mockResolvedValue({
502
+ exitCode: 0,
503
+ stdout: 'Review result: Success\n=== SUGGESTED IMPROVEMENT START ===\n# Refined Strategy\n=== SUGGESTED IMPROVEMENT END ==='
504
+ } as any);
505
+
506
+ const response = await app.request('/api/workflows/templates/test-id/analyze', {
507
+ method: 'POST',
508
+ headers: { 'Content-Type': 'application/json' },
509
+ body: JSON.stringify({
510
+ content: 'My Strategy guidelines',
511
+ assistant: 'antigravity',
512
+ comment: 'Check for timeouts'
513
+ })
514
+ });
515
+
516
+ expect(response.status).toBe(200);
517
+ const data = await response.json();
518
+ expect(data.analysis).toContain('Review result: Success');
519
+ expect(data.suggestedImprovement).toBe('# Refined Strategy');
520
+ expect(execa).toHaveBeenCalled();
521
+
522
+ const calledArgs = vi.mocked(execa).mock.calls[0][1];
523
+ expect(JSON.stringify(calledArgs)).toContain('Check for timeouts');
524
+ });
525
+
526
+ describe('POST /api/workspace/suggest-workflow', () => {
527
+ it('should suggest a workflow using heuristics when local LLM is disabled', async () => {
528
+ vi.spyOn(config, 'loadConfig').mockResolvedValue({
529
+ version: '1.0',
530
+ devDir: '/dev',
531
+ workspacesDir: '/dev/workspaces',
532
+ defaultAssistant: null,
533
+ scanDepth: 2,
534
+ localLlm: { enabled: false, provider: 'ollama', endpoint: 'http://localhost:11434', model: 'qwen' }
535
+ });
536
+
537
+ const response = await app.request('/api/workspace/suggest-workflow', {
538
+ method: 'POST',
539
+ headers: { 'Content-Type': 'application/json' },
540
+ body: JSON.stringify({
541
+ description: 'Fix a typo in README.md and update comments',
542
+ repos: [{ name: 'my-project', path: '/dev/my-project', defaultBranch: 'main' }]
543
+ })
544
+ });
545
+
546
+ expect(response.status).toBe(200);
547
+ const data = await response.json();
548
+ expect(data.success).toBe(true);
549
+ expect(data.difficulty).toBe('simple');
550
+ expect(data.suggestedWorkflowId).toBe('solo-developer');
551
+ expect(data.customInstructions).toContain('Solo Developer');
552
+ });
553
+
554
+ it('should suggest a complex workflow when description contains complex keywords', async () => {
555
+ vi.spyOn(config, 'loadConfig').mockResolvedValue({
556
+ version: '1.0',
557
+ devDir: '/dev',
558
+ workspacesDir: '/dev/workspaces',
559
+ defaultAssistant: null,
560
+ scanDepth: 2,
561
+ localLlm: { enabled: false, provider: 'ollama', endpoint: 'http://localhost:11434', model: 'qwen' }
562
+ });
563
+
564
+ const response = await app.request('/api/workspace/suggest-workflow', {
565
+ method: 'POST',
566
+ headers: { 'Content-Type': 'application/json' },
567
+ body: JSON.stringify({
568
+ description: 'Refactor database schema and migrate data to postgres',
569
+ repos: [{ name: 'my-project', path: '/dev/my-project', defaultBranch: 'main' }]
570
+ })
571
+ });
572
+
573
+ expect(response.status).toBe(200);
574
+ const data = await response.json();
575
+ expect(data.success).toBe(true);
576
+ expect(data.difficulty).toBe('complex');
577
+ expect(data.suggestedWorkflowId).toBe('plan-implement-review');
578
+ expect(data.customInstructions).toContain('Plan, Implement, Review');
579
+ });
580
+
581
+ it('should call local LLM and return suggested workflow when LLM is enabled', async () => {
582
+ vi.spyOn(config, 'loadConfig').mockResolvedValue({
583
+ version: '1.0',
584
+ devDir: '/dev',
585
+ workspacesDir: '/dev/workspaces',
586
+ defaultAssistant: null,
587
+ scanDepth: 2,
588
+ localLlm: { enabled: true, provider: 'ollama', endpoint: 'http://localhost:11434', model: 'qwen' }
589
+ });
590
+
591
+ vi.spyOn(localAi, 'callLocalLlm').mockResolvedValue(JSON.stringify({
592
+ difficulty: 'moderate',
593
+ rationale: 'LLM selected moderate strategy.',
594
+ suggestedWorkflowId: 'research-verify',
595
+ customInstructions: '# LLM Custom Instructions'
596
+ }));
597
+
598
+ const response = await app.request('/api/workspace/suggest-workflow', {
599
+ method: 'POST',
600
+ headers: { 'Content-Type': 'application/json' },
601
+ body: JSON.stringify({
602
+ description: 'Implement new UI component',
603
+ repos: [{ name: 'my-project', path: '/dev/my-project', defaultBranch: 'main' }]
604
+ })
605
+ });
606
+
607
+ expect(response.status).toBe(200);
608
+ const data = await response.json();
609
+ expect(data.success).toBe(true);
610
+ expect(data.difficulty).toBe('moderate');
611
+ expect(data.rationale).toBe('LLM selected moderate strategy.');
612
+ expect(data.suggestedWorkflowId).toBe('research-verify');
613
+ expect(data.customInstructions).toBe('# LLM Custom Instructions');
614
+ expect(localAi.callLocalLlm).toHaveBeenCalled();
615
+ });
616
+ });
617
+ });
618
+ });