@mrpatronz/nexusflow 0.2.17 → 0.2.19

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 (320) 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 +36 -54
  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/sync.d.ts +44 -0
  89. package/dist/core/sync.d.ts.map +1 -0
  90. package/dist/core/sync.js +82 -0
  91. package/dist/core/sync.js.map +1 -0
  92. package/dist/core/workspace-state.d.ts +51 -0
  93. package/dist/core/workspace-state.d.ts.map +1 -0
  94. package/dist/core/workspace-state.js +108 -0
  95. package/dist/core/workspace-state.js.map +1 -0
  96. package/dist/core/workspace-state.test.d.ts +2 -0
  97. package/dist/core/workspace-state.test.d.ts.map +1 -0
  98. package/dist/core/workspace-state.test.js +84 -0
  99. package/dist/core/workspace-state.test.js.map +1 -0
  100. package/dist/core/workspace.d.ts.map +1 -1
  101. package/dist/core/workspace.js +110 -16
  102. package/dist/core/workspace.js.map +1 -1
  103. package/dist/generators/base.d.ts.map +1 -1
  104. package/dist/generators/base.js +80 -79
  105. package/dist/generators/base.js.map +1 -1
  106. package/dist/generators/codex.js +11 -11
  107. package/dist/generators/copilot.js +17 -17
  108. package/dist/generators/cursor.js +5 -5
  109. package/dist/generators/diff-context.d.ts +6 -0
  110. package/dist/generators/diff-context.d.ts.map +1 -0
  111. package/dist/generators/diff-context.js +68 -0
  112. package/dist/generators/diff-context.js.map +1 -0
  113. package/dist/generators/index.d.ts +1 -1
  114. package/dist/generators/index.d.ts.map +1 -1
  115. package/dist/generators/index.js +125 -68
  116. package/dist/generators/index.js.map +1 -1
  117. package/dist/generators/map-generator.d.ts.map +1 -1
  118. package/dist/generators/map-generator.js +21 -25
  119. package/dist/generators/map-generator.js.map +1 -1
  120. package/dist/generators/plan-generator.d.ts.map +1 -1
  121. package/dist/generators/plan-generator.js +3 -5
  122. package/dist/generators/plan-generator.js.map +1 -1
  123. package/dist/generators/skills-generator.js +28 -28
  124. package/dist/gui/assets/index-Bdbld6yY.css +1 -0
  125. package/dist/gui/assets/index-D8iJPvnk.js +26 -0
  126. package/dist/gui/icons.svg +24 -24
  127. package/dist/gui/index.html +18 -18
  128. package/dist/index.js +131 -20
  129. package/dist/index.js.map +1 -1
  130. package/dist/mcp/server.d.ts.map +1 -1
  131. package/dist/mcp/server.js +42 -0
  132. package/dist/mcp/server.js.map +1 -1
  133. package/dist/server.d.ts.map +1 -1
  134. package/dist/server.js +140 -56
  135. package/dist/server.js.map +1 -1
  136. package/dist/server.test.js +105 -48
  137. package/dist/server.test.js.map +1 -1
  138. package/dist/types.d.ts +38 -2
  139. package/dist/types.d.ts.map +1 -1
  140. package/dist/utils/local-ai.d.ts.map +1 -1
  141. package/dist/utils/local-ai.js +7 -3
  142. package/dist/utils/local-ai.js.map +1 -1
  143. package/dist/utils/multi-git.d.ts +33 -8
  144. package/dist/utils/multi-git.d.ts.map +1 -1
  145. package/dist/utils/multi-git.js +91 -19
  146. package/dist/utils/multi-git.js.map +1 -1
  147. package/dist/utils/multi-git.test.d.ts +2 -0
  148. package/dist/utils/multi-git.test.d.ts.map +1 -0
  149. package/dist/utils/multi-git.test.js +125 -0
  150. package/dist/utils/multi-git.test.js.map +1 -0
  151. package/dist/utils/update-check.d.ts.map +1 -1
  152. package/dist/utils/update-check.js +0 -28
  153. package/dist/utils/update-check.js.map +1 -1
  154. package/dist/utils/workflow-advisor.d.ts +16 -0
  155. package/dist/utils/workflow-advisor.d.ts.map +1 -0
  156. package/dist/utils/workflow-advisor.js +100 -0
  157. package/dist/utils/workflow-advisor.js.map +1 -0
  158. package/extension/package-lock.json +6044 -2673
  159. package/extension/package.json +117 -108
  160. package/extension/src/extension.ts +653 -631
  161. package/extension/tsconfig.json +21 -21
  162. package/gui/README.md +73 -73
  163. package/gui/e2e/dashboard.spec.ts +61 -0
  164. package/gui/e2e/wizard.spec.ts +346 -346
  165. package/gui/eslint.config.js +26 -26
  166. package/gui/index.html +17 -17
  167. package/gui/package-lock.json +3137 -3079
  168. package/gui/package.json +37 -36
  169. package/gui/playwright.config.ts +41 -41
  170. package/gui/public/icons.svg +24 -24
  171. package/gui/src/App.css +1 -1
  172. package/gui/src/App.tsx +3573 -3407
  173. package/gui/src/app/AppSidebar.tsx +69 -0
  174. package/gui/src/assets/vite.svg +1 -1
  175. package/gui/src/components/AddRepoPicker.tsx +81 -0
  176. package/gui/src/components/ui/Button.tsx +40 -0
  177. package/gui/src/components/ui/Card.tsx +6 -0
  178. package/gui/src/components/ui/EmptyState.tsx +22 -0
  179. package/gui/src/components/ui/Input.tsx +21 -0
  180. package/gui/src/components/ui/Menu.tsx +71 -0
  181. package/gui/src/components/ui/Modal.tsx +39 -0
  182. package/gui/src/components/ui/PageHeader.tsx +21 -0
  183. package/gui/src/components/ui/RepoStatusStrip.tsx +33 -0
  184. package/gui/src/components/ui/Skeleton.tsx +5 -0
  185. package/gui/src/components/ui/StatusPill.tsx +36 -0
  186. package/gui/src/components/ui/Tabs.tsx +40 -0
  187. package/gui/src/components/ui/cn.ts +4 -0
  188. package/gui/src/components/ui/index.ts +16 -0
  189. package/gui/src/features/changes/ChangesViewer.tsx +388 -388
  190. package/gui/src/features/knowledge/KnowledgeBase.tsx +84 -84
  191. package/gui/src/features/onboarding/OnboardingWizard.tsx +230 -230
  192. package/gui/src/features/plan/ImplementationPlan.tsx +32 -32
  193. package/gui/src/features/services/ServiceConsole.tsx +215 -250
  194. package/gui/src/features/sessions/SessionHistory.tsx +195 -195
  195. package/gui/src/index.css +167 -152
  196. package/gui/src/lib/status.ts +24 -0
  197. package/gui/src/main.tsx +94 -94
  198. package/gui/src/pages/DashboardPage.tsx +129 -0
  199. package/gui/src/pages/WorkspacesPage.tsx +352 -0
  200. package/gui/src/types.ts +81 -62
  201. package/gui/tsconfig.app.json +25 -25
  202. package/gui/tsconfig.json +7 -7
  203. package/gui/tsconfig.node.json +24 -24
  204. package/gui/vite.config.ts +12 -12
  205. package/package.json +55 -56
  206. package/resources/workflows/plan-implement-review.md +8 -8
  207. package/resources/workflows/research-verify.md +6 -6
  208. package/resources/workflows/solo-developer.md +3 -3
  209. package/scripts/simulate-workspaces.ts +55 -55
  210. package/skills-lock.json +11 -0
  211. package/src/analyzers/detect-apis.ts +290 -290
  212. package/src/analyzers/detect-deps.ts +315 -315
  213. package/src/analyzers/detect-existing.ts +74 -74
  214. package/src/analyzers/detect-ports.ts +110 -110
  215. package/src/analyzers/index.ts +97 -97
  216. package/src/analyzers/messaging-analyzer.ts +254 -254
  217. package/src/analyzers/readme-summarizer.ts +102 -102
  218. package/src/analyzers/run-analyzer.ts +269 -269
  219. package/src/analyzers/tech-stack.ts +283 -283
  220. package/src/commands/adapter.ts +348 -0
  221. package/src/commands/add-repo.ts +156 -157
  222. package/src/commands/commands.test.ts +156 -155
  223. package/src/commands/commit.ts +131 -131
  224. package/src/commands/config.ts +52 -0
  225. package/src/commands/create.ts +204 -207
  226. package/src/commands/desktop.ts +128 -128
  227. package/src/commands/diff.ts +125 -125
  228. package/src/commands/doctor.ts +370 -357
  229. package/src/commands/handoff.ts +266 -266
  230. package/src/commands/init.ts +134 -134
  231. package/src/commands/list.ts +46 -46
  232. package/src/commands/logs.ts +63 -63
  233. package/src/commands/mcp.ts +94 -92
  234. package/src/commands/open.ts +129 -129
  235. package/src/commands/refresh.ts +137 -147
  236. package/src/commands/remove.ts +98 -98
  237. package/src/commands/start.ts +117 -117
  238. package/src/commands/status.ts +54 -54
  239. package/src/commands/stop.ts +55 -55
  240. package/src/commands/sync.ts +125 -151
  241. package/src/commands/tui.ts +484 -424
  242. package/src/commands/ui.ts +111 -111
  243. package/src/core/adapters/local-storage.ts +66 -0
  244. package/src/core/adapters/obsidian-storage.ts +151 -0
  245. package/src/core/adapters/registry.ts +44 -0
  246. package/src/core/adapters/storage.test.ts +174 -0
  247. package/src/core/adapters/vault-storage.ts +86 -0
  248. package/src/core/config.test.ts +96 -96
  249. package/src/core/config.ts +140 -115
  250. package/src/core/graph.ts +344 -344
  251. package/src/core/plugins/index.ts +17 -0
  252. package/src/core/plugins/loader.ts +36 -0
  253. package/src/core/ports/storage.ts +71 -0
  254. package/src/core/scanner.test.ts +61 -61
  255. package/src/core/scanner.ts +91 -91
  256. package/src/core/storage.ts +37 -0
  257. package/src/core/sync.ts +121 -0
  258. package/src/core/workspace-state.test.ts +108 -0
  259. package/src/core/workspace-state.ts +130 -0
  260. package/src/core/workspace.ts +487 -380
  261. package/src/core/worktree.ts +120 -120
  262. package/src/generators/antigravity.ts +32 -32
  263. package/src/generators/base.ts +243 -240
  264. package/src/generators/claude.ts +34 -34
  265. package/src/generators/codex.ts +48 -48
  266. package/src/generators/copilot.ts +56 -56
  267. package/src/generators/cursor.ts +44 -44
  268. package/src/generators/diff-context.ts +75 -0
  269. package/src/generators/index.ts +322 -250
  270. package/src/generators/map-generator.test.ts +159 -159
  271. package/src/generators/map-generator.ts +539 -542
  272. package/src/generators/plan-generator.ts +483 -481
  273. package/src/generators/skills-generator.ts +259 -259
  274. package/src/index.ts +539 -426
  275. package/src/mcp/server.ts +338 -294
  276. package/src/orchestration/detect.ts +313 -313
  277. package/src/orchestration/index.ts +7 -7
  278. package/src/orchestration/runner.ts +283 -283
  279. package/src/server.test.ts +618 -555
  280. package/src/server.ts +1402 -1308
  281. package/src/types.ts +451 -408
  282. package/src/utils/detect-ai.test.ts +44 -44
  283. package/src/utils/detect-ai.ts +84 -84
  284. package/src/utils/detect-editors.test.ts +48 -48
  285. package/src/utils/detect-editors.ts +57 -57
  286. package/src/utils/git.test.ts +74 -74
  287. package/src/utils/git.ts +117 -117
  288. package/src/utils/local-ai.test.ts +130 -130
  289. package/src/utils/local-ai.ts +116 -111
  290. package/src/utils/multi-git.test.ts +158 -0
  291. package/src/utils/multi-git.ts +405 -313
  292. package/src/utils/prompts.ts +209 -209
  293. package/src/utils/session-finder.ts +483 -483
  294. package/src/utils/system-scanner.test.ts +89 -89
  295. package/src/utils/system-scanner.ts +96 -96
  296. package/src/utils/update-check.ts +286 -309
  297. package/src/utils/workflow-advisor.ts +118 -0
  298. package/src/utils/workflows.test.ts +114 -114
  299. package/src/utils/workflows.ts +178 -178
  300. package/tsconfig.json +19 -19
  301. package/vitest.config.ts +14 -14
  302. package/.github/workflows/release.yml +0 -112
  303. package/dist/commands/pack.d.ts +0 -7
  304. package/dist/commands/pack.d.ts.map +0 -1
  305. package/dist/commands/pack.js +0 -64
  306. package/dist/commands/pack.js.map +0 -1
  307. package/dist/core/packer.d.ts +0 -14
  308. package/dist/core/packer.d.ts.map +0 -1
  309. package/dist/core/packer.js +0 -87
  310. package/dist/core/packer.js.map +0 -1
  311. package/dist/core/packer.test.d.ts +0 -2
  312. package/dist/core/packer.test.d.ts.map +0 -1
  313. package/dist/core/packer.test.js +0 -82
  314. package/dist/core/packer.test.js.map +0 -1
  315. package/dist/gui/assets/index-Ct3qq-X4.js +0 -25
  316. package/dist/gui/assets/index-P_ZrgHXg.css +0 -1
  317. package/gui/src/features/workspace/WorkspaceList.tsx +0 -666
  318. package/src/commands/pack.ts +0 -73
  319. package/src/core/packer.test.ts +0 -99
  320. 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
+ });