@hellcoder/companion 0.96.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,4655 @@
1
+ import { vi, describe, it, expect, beforeEach } from "vitest";
2
+
3
+ // Mock auth-manager so all test requests pass the auth middleware
4
+ vi.mock("./auth-manager.js", () => ({
5
+ verifyToken: vi.fn(() => true),
6
+ getToken: vi.fn(() => "test-token-for-routes"),
7
+ getLanAddress: vi.fn(() => "192.168.1.100"),
8
+ _resetForTest: vi.fn(),
9
+ }));
10
+
11
+ // Mock env-manager and git-utils modules before any imports
12
+ vi.mock("./env-manager.js", () => ({
13
+ listEnvs: vi.fn(() => []),
14
+ getEnv: vi.fn(() => null),
15
+ createEnv: vi.fn(),
16
+ updateEnv: vi.fn(),
17
+ deleteEnv: vi.fn(),
18
+ }));
19
+
20
+ // Mock sandbox-manager — sandboxes now own Docker/container config (separated from envs)
21
+ vi.mock("./sandbox-manager.js", () => ({
22
+ listSandboxes: vi.fn(() => []),
23
+ getSandbox: vi.fn(() => null),
24
+ createSandbox: vi.fn(),
25
+ updateSandbox: vi.fn(),
26
+ deleteSandbox: vi.fn(() => false),
27
+ }));
28
+
29
+ vi.mock("./prompt-manager.js", () => ({
30
+ listPrompts: vi.fn(() => []),
31
+ getPrompt: vi.fn(() => null),
32
+ createPrompt: vi.fn(),
33
+ updatePrompt: vi.fn(),
34
+ deletePrompt: vi.fn(() => false),
35
+ }));
36
+
37
+ vi.mock("node:child_process", () => ({
38
+ execSync: vi.fn(() => ""),
39
+ execFileSync: vi.fn(() => ""),
40
+ }));
41
+
42
+ const mockResolveBinary = vi.hoisted(() => vi.fn((_name: string) => null as string | null));
43
+ vi.mock("./path-resolver.js", () => ({
44
+ resolveBinary: mockResolveBinary,
45
+ }));
46
+
47
+ vi.mock("node:fs", async (importOriginal) => {
48
+ const actual = await importOriginal<typeof import("node:fs")>();
49
+ return {
50
+ ...actual,
51
+ existsSync: vi.fn(() => false),
52
+ readFileSync: vi.fn(() => ""),
53
+ };
54
+ });
55
+
56
+ vi.mock("./git-utils.js", () => ({
57
+ getRepoInfo: vi.fn(() => null),
58
+ listBranches: vi.fn(() => []),
59
+ listWorktrees: vi.fn(() => []),
60
+ ensureWorktree: vi.fn(),
61
+ gitFetch: vi.fn(() => ({ success: true, output: "" })),
62
+ gitPull: vi.fn(() => ({ success: true, output: "" })),
63
+ checkoutBranch: vi.fn(),
64
+ checkoutOrCreateBranch: vi.fn(() => ({ created: false })),
65
+ removeWorktree: vi.fn(),
66
+ isWorktreeDirty: vi.fn(() => false),
67
+ }));
68
+
69
+ vi.mock("./session-names.js", () => ({
70
+ getName: vi.fn(() => undefined),
71
+ setName: vi.fn(),
72
+ getAllNames: vi.fn(() => ({})),
73
+ removeName: vi.fn(),
74
+ _resetForTest: vi.fn(),
75
+ }));
76
+
77
+ vi.mock("./settings-manager.js", () => ({
78
+ DEFAULT_ANTHROPIC_MODEL: "claude-sonnet-4-6",
79
+ getSettings: vi.fn(() => ({
80
+ anthropicApiKey: "",
81
+ anthropicModel: "claude-sonnet-4-6",
82
+ linearApiKey: "",
83
+ linearAutoTransition: false,
84
+ linearAutoTransitionStateId: "",
85
+ linearAutoTransitionStateName: "",
86
+ linearArchiveTransition: false,
87
+ linearArchiveTransitionStateId: "",
88
+ linearArchiveTransitionStateName: "",
89
+ linearOAuthClientId: "",
90
+ linearOAuthClientSecret: "",
91
+ linearOAuthWebhookSecret: "",
92
+ linearOAuthAccessToken: "",
93
+ linearOAuthRefreshToken: "",
94
+ claudeCodeOAuthToken: "",
95
+ openaiApiKey: "",
96
+ onboardingCompleted: false,
97
+ aiValidationEnabled: false,
98
+ aiValidationAutoApprove: true,
99
+ aiValidationAutoDeny: false,
100
+ publicUrl: "",
101
+ updateChannel: "stable",
102
+ dockerAutoUpdate: false,
103
+ updatedAt: 0,
104
+ })),
105
+ updateSettings: vi.fn((patch) => ({
106
+ anthropicApiKey: patch.anthropicApiKey ?? "",
107
+ anthropicModel: patch.anthropicModel ?? "claude-sonnet-4-6",
108
+ linearApiKey: patch.linearApiKey ?? "",
109
+ linearAutoTransition: patch.linearAutoTransition ?? false,
110
+ linearAutoTransitionStateId: patch.linearAutoTransitionStateId ?? "",
111
+ linearAutoTransitionStateName: patch.linearAutoTransitionStateName ?? "",
112
+ linearArchiveTransition: patch.linearArchiveTransition ?? false,
113
+ linearArchiveTransitionStateId: patch.linearArchiveTransitionStateId ?? "",
114
+ linearArchiveTransitionStateName: patch.linearArchiveTransitionStateName ?? "",
115
+ linearOAuthClientId: patch.linearOAuthClientId ?? "",
116
+ linearOAuthClientSecret: patch.linearOAuthClientSecret ?? "",
117
+ linearOAuthWebhookSecret: patch.linearOAuthWebhookSecret ?? "",
118
+ linearOAuthAccessToken: patch.linearOAuthAccessToken ?? "",
119
+ linearOAuthRefreshToken: patch.linearOAuthRefreshToken ?? "",
120
+ aiValidationEnabled: patch.aiValidationEnabled ?? false,
121
+ aiValidationAutoApprove: patch.aiValidationAutoApprove ?? true,
122
+ aiValidationAutoDeny: patch.aiValidationAutoDeny ?? false,
123
+ publicUrl: patch.publicUrl ?? "",
124
+ updateChannel: patch.updateChannel ?? "stable",
125
+ dockerAutoUpdate: patch.dockerAutoUpdate ?? false,
126
+ updatedAt: Date.now(),
127
+ })),
128
+ }));
129
+
130
+ const mockGetLinearIssue = vi.hoisted(() => vi.fn(() => undefined as any));
131
+ vi.mock("./session-linear-issues.js", () => ({
132
+ getLinearIssue: mockGetLinearIssue,
133
+ setLinearIssue: vi.fn(),
134
+ removeLinearIssue: vi.fn(),
135
+ getAllLinearIssues: vi.fn(() => ({})),
136
+ _resetForTest: vi.fn(),
137
+ }));
138
+
139
+ const mockTransitionLinearIssue = vi.hoisted(() => vi.fn(async () => ({ ok: true, issue: { id: "i1", identifier: "ENG-1", stateName: "Backlog", stateType: "backlog" } } as { ok: boolean; error?: string; issue?: { id: string; identifier: string; stateName: string; stateType: string } })));
140
+ const mockFetchLinearTeamStates = vi.hoisted(() => vi.fn(async () => [
141
+ { id: "team-1", key: "ENG", name: "Engineering", states: [
142
+ { id: "state-backlog", name: "Backlog", type: "backlog" },
143
+ { id: "state-inprogress", name: "In Progress", type: "started" },
144
+ { id: "state-done", name: "Done", type: "completed" },
145
+ ] },
146
+ ]));
147
+ vi.mock("./routes/linear-routes.js", async (importOriginal) => {
148
+ const actual = await importOriginal<typeof import("./routes/linear-routes.js")>();
149
+ return {
150
+ ...actual,
151
+ transitionLinearIssue: mockTransitionLinearIssue,
152
+ fetchLinearTeamStates: mockFetchLinearTeamStates,
153
+ };
154
+ });
155
+
156
+ vi.mock("./linear-project-manager.js", () => ({
157
+ listMappings: vi.fn(() => []),
158
+ getMapping: vi.fn(() => null),
159
+ upsertMapping: vi.fn((repoRoot: string, data: { projectId: string; projectName: string }) => ({
160
+ repoRoot,
161
+ ...data,
162
+ createdAt: Date.now(),
163
+ updatedAt: Date.now(),
164
+ })),
165
+ removeMapping: vi.fn(() => false),
166
+ _resetForTest: vi.fn(),
167
+ }));
168
+
169
+ // Mock linear-connections to isolate from the file-based connection store.
170
+ // resolveApiKey returns a valid key by default; tests that need "no key"
171
+ // override it to return null.
172
+ vi.mock("./linear-connections.js", () => ({
173
+ listConnections: vi.fn(() => []),
174
+ getConnection: vi.fn(() => null),
175
+ getDefaultConnection: vi.fn(() => null),
176
+ createConnection: vi.fn(),
177
+ updateConnection: vi.fn(),
178
+ deleteConnection: vi.fn(),
179
+ resolveApiKey: vi.fn(() => ({ apiKey: "lin_api_123", connectionId: "test-conn" })),
180
+ _resetForTest: vi.fn(),
181
+ }));
182
+
183
+ vi.mock("./codex-container-auth.js", () => ({
184
+ hasContainerCodexAuth: vi.fn(() => false),
185
+ }));
186
+
187
+ const mockDiscoverClaudeSessions = vi.hoisted(() => vi.fn(
188
+ (_options?: { limit?: number }) =>
189
+ [] as Array<{
190
+ sessionId: string;
191
+ cwd: string;
192
+ gitBranch?: string;
193
+ slug?: string;
194
+ lastActivityAt: number;
195
+ sourceFile: string;
196
+ }>
197
+ ));
198
+ vi.mock("./claude-session-discovery.js", () => ({
199
+ discoverClaudeSessions: mockDiscoverClaudeSessions,
200
+ }));
201
+
202
+ const mockGetClaudeSessionHistoryPage = vi.hoisted(() => vi.fn(
203
+ (_options?: { sessionId: string; limit?: number; cursor?: number }) =>
204
+ null as {
205
+ sourceFile: string;
206
+ nextCursor: number;
207
+ hasMore: boolean;
208
+ totalMessages: number;
209
+ messages: Array<{ id: string; role: "user" | "assistant"; content: string; timestamp: number }>;
210
+ } | null
211
+ ));
212
+ vi.mock("./claude-session-history.js", () => ({
213
+ getClaudeSessionHistoryPage: mockGetClaudeSessionHistoryPage,
214
+ }));
215
+
216
+ const mockGetUsageLimits = vi.hoisted(() => vi.fn());
217
+ const mockUpdateCheckerState = vi.hoisted(() => ({
218
+ currentVersion: "0.22.1",
219
+ latestVersion: null as string | null,
220
+ lastChecked: 0,
221
+ isServiceMode: false,
222
+ checking: false,
223
+ updateInProgress: false,
224
+ }));
225
+ const mockCheckForUpdate = vi.hoisted(() => vi.fn(async () => {}));
226
+ const mockIsUpdateAvailable = vi.hoisted(() => vi.fn(() => false));
227
+ const mockSetUpdateInProgress = vi.hoisted(() => vi.fn());
228
+
229
+ vi.mock("./usage-limits.js", () => ({
230
+ getUsageLimits: mockGetUsageLimits,
231
+ }));
232
+
233
+ vi.mock("./update-checker.js", () => ({
234
+ getUpdateState: vi.fn(() => ({ ...mockUpdateCheckerState })),
235
+ checkForUpdate: mockCheckForUpdate,
236
+ isUpdateAvailable: mockIsUpdateAvailable,
237
+ setUpdateInProgress: mockSetUpdateInProgress,
238
+ }));
239
+
240
+ // Mock image-pull-manager — default: images are always ready
241
+ const mockImagePullIsReady = vi.hoisted(() => vi.fn(() => true));
242
+ interface MockImagePullState {
243
+ image: string;
244
+ status: "idle" | "pulling" | "ready" | "error";
245
+ progress: string[];
246
+ error?: string;
247
+ startedAt?: number;
248
+ completedAt?: number;
249
+ }
250
+ const mockImagePullGetState = vi.hoisted(() => vi.fn(
251
+ (image: string): MockImagePullState => ({
252
+ image,
253
+ status: "ready",
254
+ progress: [],
255
+ })
256
+ ));
257
+ const mockImagePullEnsureImage = vi.hoisted(() => vi.fn());
258
+ const mockImagePullWaitForReady = vi.hoisted(() => vi.fn(async () => true));
259
+ const mockImagePullPull = vi.hoisted(() => vi.fn());
260
+ const mockImagePullOnProgress = vi.hoisted(() => vi.fn(() => () => {}));
261
+
262
+ vi.mock("./image-pull-manager.js", () => ({
263
+ imagePullManager: {
264
+ isReady: mockImagePullIsReady,
265
+ getState: mockImagePullGetState,
266
+ ensureImage: mockImagePullEnsureImage,
267
+ waitForReady: mockImagePullWaitForReady,
268
+ pull: mockImagePullPull,
269
+ onProgress: mockImagePullOnProgress,
270
+ },
271
+ }));
272
+
273
+ import { Hono } from "hono";
274
+ import { execSync } from "node:child_process";
275
+ import { existsSync, readFileSync } from "node:fs";
276
+ import { createRoutes } from "./routes.js";
277
+ import * as envManager from "./env-manager.js";
278
+ import * as sandboxManager from "./sandbox-manager.js";
279
+ import * as promptManager from "./prompt-manager.js";
280
+ import * as gitUtils from "./git-utils.js";
281
+ import * as sessionNames from "./session-names.js";
282
+ import * as settingsManager from "./settings-manager.js";
283
+ import * as linearProjectManager from "./linear-project-manager.js";
284
+ import { resolveApiKey } from "./linear-connections.js";
285
+ import { containerManager } from "./container-manager.js";
286
+
287
+ // ─── Mock factories ──────────────────────────────────────────────────────────
288
+
289
+ function createMockLauncher() {
290
+ return {
291
+ launch: vi.fn(() => ({
292
+ sessionId: "session-1",
293
+ state: "starting",
294
+ cwd: "/test",
295
+ createdAt: Date.now(),
296
+ })),
297
+ kill: vi.fn(async () => true),
298
+ relaunch: vi.fn(async () => ({ ok: true })),
299
+ listSessions: vi.fn(() => []),
300
+ getSession: vi.fn(),
301
+ setArchived: vi.fn(),
302
+ removeSession: vi.fn(),
303
+ } as any;
304
+ }
305
+
306
+ function createMockBridge() {
307
+ return {
308
+ closeSession: vi.fn(),
309
+ getSession: vi.fn(() => null),
310
+ getAllSessions: vi.fn(() => []),
311
+ getCodexRateLimits: vi.fn(() => null),
312
+ markContainerized: vi.fn(),
313
+ prePopulateCommands: vi.fn(),
314
+ broadcastNameUpdate: vi.fn(),
315
+ injectSystemPrompt: vi.fn(),
316
+ } as any;
317
+ }
318
+
319
+ function createMockStore() {
320
+ return {
321
+ setArchived: vi.fn(() => true),
322
+ } as any;
323
+ }
324
+
325
+ function createMockTracker() {
326
+ return {
327
+ addMapping: vi.fn(),
328
+ getBySession: vi.fn(() => null),
329
+ removeBySession: vi.fn(),
330
+ isWorktreeInUse: vi.fn(() => false),
331
+ } as any;
332
+ }
333
+
334
+ function createMockOrchestrator() {
335
+ return {
336
+ createSession: vi.fn(async () => ({
337
+ ok: true,
338
+ session: { sessionId: "session-1", state: "starting", cwd: "/test", createdAt: Date.now() },
339
+ })),
340
+ createSessionStreaming: vi.fn(async () => ({
341
+ ok: true,
342
+ session: { sessionId: "session-1", state: "starting", cwd: "/test", createdAt: Date.now() },
343
+ })),
344
+ killSession: vi.fn(async () => ({ ok: true })),
345
+ relaunchSession: vi.fn(async () => ({ ok: true })),
346
+ deleteSession: vi.fn(async () => ({ ok: true })),
347
+ archiveSession: vi.fn(async () => ({ ok: true })),
348
+ unarchiveSession: vi.fn(() => ({ ok: true })),
349
+ clearAutoRelaunchCount: vi.fn(),
350
+ getSession: vi.fn(),
351
+ } as any;
352
+ }
353
+
354
+ // ─── Test setup ──────────────────────────────────────────────────────────────
355
+
356
+ let app: Hono;
357
+ let orchestrator: ReturnType<typeof createMockOrchestrator>;
358
+ let launcher: ReturnType<typeof createMockLauncher>;
359
+ let bridge: ReturnType<typeof createMockBridge>;
360
+ let sessionStore: ReturnType<typeof createMockStore>;
361
+ let tracker: ReturnType<typeof createMockTracker>;
362
+ let terminalManager: { getInfo: ReturnType<typeof vi.fn>; spawn: ReturnType<typeof vi.fn>; kill: ReturnType<typeof vi.fn> };
363
+
364
+ beforeEach(() => {
365
+ vi.clearAllMocks();
366
+ // Re-establish default return value for resolveApiKey after clearAllMocks
367
+ vi.mocked(resolveApiKey).mockReturnValue({ apiKey: "lin_api_123", connectionId: "test-conn" });
368
+ mockDiscoverClaudeSessions.mockReturnValue([]);
369
+ mockGetClaudeSessionHistoryPage.mockReturnValue(null);
370
+ mockUpdateCheckerState.currentVersion = "0.22.1";
371
+ mockUpdateCheckerState.latestVersion = null;
372
+ mockUpdateCheckerState.lastChecked = 0;
373
+ mockUpdateCheckerState.isServiceMode = false;
374
+ mockUpdateCheckerState.checking = false;
375
+ mockUpdateCheckerState.updateInProgress = false;
376
+ orchestrator = createMockOrchestrator();
377
+ launcher = createMockLauncher();
378
+ bridge = createMockBridge();
379
+ sessionStore = createMockStore();
380
+ tracker = createMockTracker();
381
+ terminalManager = { getInfo: vi.fn(() => null), spawn: vi.fn(() => ""), kill: vi.fn() };
382
+ app = new Hono();
383
+ app.route("/api", createRoutes(orchestrator, launcher, bridge, terminalManager as any));
384
+
385
+ // Default no-op mocks for container workspace isolation (called during container session creation)
386
+ vi.spyOn(containerManager, "copyWorkspaceToContainer").mockResolvedValue(undefined);
387
+ vi.spyOn(containerManager, "reseedGitAuth").mockImplementation(() => {});
388
+
389
+ // Default: images are always ready via pull manager
390
+ mockImagePullIsReady.mockReturnValue(true);
391
+ mockImagePullGetState.mockImplementation((image: string) => ({
392
+ image,
393
+ status: "ready" as const,
394
+ progress: [],
395
+ }));
396
+ mockImagePullWaitForReady.mockResolvedValue(true);
397
+ });
398
+
399
+ describe("POST /api/terminal/kill", () => {
400
+ it("returns 400 when terminalId is missing", async () => {
401
+ const res = await app.request("/api/terminal/kill", {
402
+ method: "POST",
403
+ headers: { "Content-Type": "application/json" },
404
+ body: JSON.stringify({}),
405
+ });
406
+
407
+ expect(res.status).toBe(400);
408
+ expect(terminalManager.kill).not.toHaveBeenCalled();
409
+ });
410
+
411
+ it("kills only the requested terminal", async () => {
412
+ const res = await app.request("/api/terminal/kill", {
413
+ method: "POST",
414
+ headers: { "Content-Type": "application/json" },
415
+ body: JSON.stringify({ terminalId: "term-1" }),
416
+ });
417
+
418
+ expect(res.status).toBe(200);
419
+ expect(terminalManager.kill).toHaveBeenCalledWith("term-1");
420
+ });
421
+ });
422
+
423
+ // ─── Sessions ────────────────────────────────────────────────────────────────
424
+
425
+ describe("POST /api/sessions/create", () => {
426
+ // Route delegates to orchestrator.createSession — detailed orchestration logic
427
+ // (env resolution, git ops, container creation, etc.) is tested in session-orchestrator.test.ts.
428
+ // Route tests verify correct delegation and HTTP response mapping.
429
+
430
+ it("delegates to orchestrator and returns session info on success", async () => {
431
+ orchestrator.createSession.mockResolvedValue({
432
+ ok: true,
433
+ session: { sessionId: "session-1", state: "starting", cwd: "/test", createdAt: Date.now() },
434
+ });
435
+
436
+ const res = await app.request("/api/sessions/create", {
437
+ method: "POST",
438
+ headers: { "Content-Type": "application/json" },
439
+ body: JSON.stringify({ model: "claude-sonnet-4-6", cwd: "/test" }),
440
+ });
441
+
442
+ expect(res.status).toBe(200);
443
+ const json = await res.json();
444
+ expect(json).toMatchObject({ sessionId: "session-1", state: "starting", cwd: "/test" });
445
+ expect(orchestrator.createSession).toHaveBeenCalledWith(
446
+ expect.objectContaining({ model: "claude-sonnet-4-6", cwd: "/test" }),
447
+ );
448
+ });
449
+
450
+ it("passes the full request body through to orchestrator", async () => {
451
+ const body = {
452
+ cwd: "/test",
453
+ resumeSessionAt: " prior-session-123 ",
454
+ forkSession: true,
455
+ backend: "codex",
456
+ branch: "feat",
457
+ useWorktree: true,
458
+ envSlug: "production",
459
+ sandboxEnabled: true,
460
+ sandboxSlug: "my-sandbox",
461
+ };
462
+ const res = await app.request("/api/sessions/create", {
463
+ method: "POST",
464
+ headers: { "Content-Type": "application/json" },
465
+ body: JSON.stringify(body),
466
+ });
467
+
468
+ expect(res.status).toBe(200);
469
+ expect(orchestrator.createSession).toHaveBeenCalledWith(body);
470
+ });
471
+
472
+ it("returns error status from orchestrator on failure", async () => {
473
+ orchestrator.createSession.mockResolvedValue({
474
+ ok: false,
475
+ error: "Invalid backend: invalid-backend",
476
+ status: 400,
477
+ });
478
+
479
+ const res = await app.request("/api/sessions/create", {
480
+ method: "POST",
481
+ headers: { "Content-Type": "application/json" },
482
+ body: JSON.stringify({ cwd: "/test", backend: "invalid-backend" }),
483
+ });
484
+
485
+ expect(res.status).toBe(400);
486
+ const json = await res.json();
487
+ expect(json.error).toContain("Invalid backend");
488
+ });
489
+
490
+ it("returns 500 status from orchestrator on internal errors", async () => {
491
+ orchestrator.createSession.mockResolvedValue({
492
+ ok: false,
493
+ error: "CLI binary not found",
494
+ status: 500,
495
+ });
496
+
497
+ const res = await app.request("/api/sessions/create", {
498
+ method: "POST",
499
+ headers: { "Content-Type": "application/json" },
500
+ body: JSON.stringify({ cwd: "/test" }),
501
+ });
502
+
503
+ expect(res.status).toBe(500);
504
+ const json = await res.json();
505
+ expect(json.error).toContain("CLI binary not found");
506
+ });
507
+
508
+ it("returns 503 from orchestrator on container startup failure", async () => {
509
+ orchestrator.createSession.mockResolvedValue({
510
+ ok: false,
511
+ error: "Docker is required but container startup failed",
512
+ status: 503,
513
+ });
514
+
515
+ const res = await app.request("/api/sessions/create", {
516
+ method: "POST",
517
+ headers: { "Content-Type": "application/json" },
518
+ body: JSON.stringify({ cwd: "/test", sandboxEnabled: true }),
519
+ });
520
+
521
+ expect(res.status).toBe(503);
522
+ const json = await res.json();
523
+ expect(json.error).toContain("Docker is required");
524
+ });
525
+
526
+ it("handles empty request body gracefully", async () => {
527
+ const res = await app.request("/api/sessions/create", {
528
+ method: "POST",
529
+ headers: { "Content-Type": "application/json" },
530
+ body: "not-json",
531
+ });
532
+
533
+ // Route catches JSON parse errors and defaults to {}
534
+ expect(res.status).toBe(200);
535
+ expect(orchestrator.createSession).toHaveBeenCalledWith({});
536
+ });
537
+ });
538
+
539
+ describe("GET /api/sessions", () => {
540
+ it("returns the list of sessions enriched with names", async () => {
541
+ const sessions = [
542
+ { sessionId: "s1", state: "running", cwd: "/a" },
543
+ { sessionId: "s2", state: "stopped", cwd: "/b" },
544
+ ];
545
+ launcher.listSessions.mockReturnValue(sessions);
546
+ vi.mocked(sessionNames.getAllNames).mockReturnValue({ s1: "Fix auth bug" });
547
+
548
+ const res = await app.request("/api/sessions", { method: "GET" });
549
+
550
+ expect(res.status).toBe(200);
551
+ const json = await res.json();
552
+ expect(json).toEqual([
553
+ {
554
+ sessionId: "s1", state: "running", cwd: "/a", name: "Fix auth bug",
555
+ gitBranch: "", gitAhead: 0, gitBehind: 0, totalLinesAdded: 0, totalLinesRemoved: 0,
556
+ },
557
+ {
558
+ sessionId: "s2", state: "stopped", cwd: "/b",
559
+ gitBranch: "", gitAhead: 0, gitBehind: 0, totalLinesAdded: 0, totalLinesRemoved: 0,
560
+ },
561
+ ]);
562
+ });
563
+
564
+ it("enriches sessions with git data from bridge state", async () => {
565
+ const sessions = [
566
+ { sessionId: "s1", state: "running", cwd: "/a" },
567
+ { sessionId: "s2", state: "running", cwd: "/b" },
568
+ ];
569
+ launcher.listSessions.mockReturnValue(sessions);
570
+ vi.mocked(sessionNames.getAllNames).mockReturnValue({});
571
+ bridge.getAllSessions.mockReturnValue([
572
+ {
573
+ session_id: "s1",
574
+ git_branch: "feature/auth",
575
+ git_ahead: 3,
576
+ git_behind: 1,
577
+ total_lines_added: 42,
578
+ total_lines_removed: 7,
579
+ },
580
+ ]);
581
+
582
+ const res = await app.request("/api/sessions", { method: "GET" });
583
+
584
+ expect(res.status).toBe(200);
585
+ const json = await res.json();
586
+ // s1 should have bridge git data
587
+ expect(json[0]).toMatchObject({
588
+ sessionId: "s1",
589
+ gitBranch: "feature/auth",
590
+ gitAhead: 3,
591
+ gitBehind: 1,
592
+ totalLinesAdded: 42,
593
+ totalLinesRemoved: 7,
594
+ });
595
+ // s2 has no bridge data — defaults to empty/zero
596
+ expect(json[1]).toMatchObject({
597
+ sessionId: "s2",
598
+ gitBranch: "",
599
+ gitAhead: 0,
600
+ gitBehind: 0,
601
+ totalLinesAdded: 0,
602
+ totalLinesRemoved: 0,
603
+ });
604
+ });
605
+
606
+ it("prefers bridge cwd over launcher cwd when available", async () => {
607
+ const sessions = [
608
+ { sessionId: "s1", state: "running", cwd: "/workspace" },
609
+ ];
610
+ launcher.listSessions.mockReturnValue(sessions);
611
+ vi.mocked(sessionNames.getAllNames).mockReturnValue({});
612
+ bridge.getAllSessions.mockReturnValue([
613
+ {
614
+ session_id: "s1",
615
+ cwd: "/home/ubuntu/companion",
616
+ },
617
+ ]);
618
+
619
+ const res = await app.request("/api/sessions", { method: "GET" });
620
+
621
+ expect(res.status).toBe(200);
622
+ const json = await res.json();
623
+ expect(json[0]).toMatchObject({
624
+ sessionId: "s1",
625
+ cwd: "/home/ubuntu/companion",
626
+ });
627
+ });
628
+ });
629
+
630
+ describe("GET /api/sessions/:id", () => {
631
+ it("returns the session when found", async () => {
632
+ const session = { sessionId: "s1", state: "running", cwd: "/test" };
633
+ launcher.getSession.mockReturnValue(session);
634
+
635
+ const res = await app.request("/api/sessions/s1", { method: "GET" });
636
+
637
+ expect(res.status).toBe(200);
638
+ const json = await res.json();
639
+ expect(json).toEqual(session);
640
+ });
641
+
642
+ it("returns 404 when session not found", async () => {
643
+ launcher.getSession.mockReturnValue(undefined);
644
+
645
+ const res = await app.request("/api/sessions/nonexistent", { method: "GET" });
646
+
647
+ expect(res.status).toBe(404);
648
+ const json = await res.json();
649
+ expect(json).toEqual({ error: "Session not found" });
650
+ });
651
+ });
652
+
653
+ describe("GET /api/claude/sessions/discover", () => {
654
+ it("returns discovered Claude sessions and forwards limit", async () => {
655
+ mockDiscoverClaudeSessions.mockReturnValue([
656
+ {
657
+ sessionId: "session-123",
658
+ cwd: "/repo",
659
+ gitBranch: "feature/branch",
660
+ slug: "calm-mountain",
661
+ lastActivityAt: 12345,
662
+ sourceFile: "/Users/test/.claude/projects/repo/session-123.jsonl",
663
+ },
664
+ ]);
665
+
666
+ const res = await app.request("/api/claude/sessions/discover?limit=250", { method: "GET" });
667
+
668
+ expect(res.status).toBe(200);
669
+ expect(mockDiscoverClaudeSessions).toHaveBeenCalledWith({ limit: 250 });
670
+ const json = await res.json();
671
+ expect(json).toEqual({
672
+ sessions: [
673
+ {
674
+ sessionId: "session-123",
675
+ cwd: "/repo",
676
+ gitBranch: "feature/branch",
677
+ slug: "calm-mountain",
678
+ lastActivityAt: 12345,
679
+ sourceFile: "/Users/test/.claude/projects/repo/session-123.jsonl",
680
+ },
681
+ ],
682
+ });
683
+ });
684
+ });
685
+
686
+ describe("GET /api/claude/sessions/:id/history", () => {
687
+ it("returns paged Claude transcript history and forwards cursor/limit", async () => {
688
+ // Validate route wiring so frontend pagination requests reach the loader with the same cursor/limit.
689
+ mockGetClaudeSessionHistoryPage.mockReturnValue({
690
+ sourceFile: "/Users/test/.claude/projects/repo/session-123.jsonl",
691
+ nextCursor: 80,
692
+ hasMore: true,
693
+ totalMessages: 140,
694
+ messages: [
695
+ {
696
+ id: "resume-session-123-user-u1",
697
+ role: "user",
698
+ content: "Prior prompt",
699
+ timestamp: 1,
700
+ },
701
+ {
702
+ id: "resume-session-123-assistant-a1",
703
+ role: "assistant",
704
+ content: "Prior answer",
705
+ timestamp: 2,
706
+ },
707
+ ],
708
+ });
709
+
710
+ const res = await app.request("/api/claude/sessions/session-123/history?limit=40&cursor=40", { method: "GET" });
711
+
712
+ expect(res.status).toBe(200);
713
+ expect(mockGetClaudeSessionHistoryPage).toHaveBeenCalledWith({
714
+ sessionId: "session-123",
715
+ limit: 40,
716
+ cursor: 40,
717
+ });
718
+ const json = await res.json();
719
+ expect(json).toMatchObject({
720
+ nextCursor: 80,
721
+ hasMore: true,
722
+ totalMessages: 140,
723
+ });
724
+ expect(json.messages).toHaveLength(2);
725
+ });
726
+
727
+ it("returns 404 when transcript history does not exist", async () => {
728
+ // Validate explicit not-found semantics so UI can render a clear empty/error state.
729
+ mockGetClaudeSessionHistoryPage.mockReturnValue(null);
730
+
731
+ const res = await app.request("/api/claude/sessions/missing/history", { method: "GET" });
732
+
733
+ expect(res.status).toBe(404);
734
+ const json = await res.json();
735
+ expect(json).toEqual({ error: "Claude session history not found" });
736
+ });
737
+ });
738
+
739
+ describe("POST /api/sessions/:id/editor/start", () => {
740
+ it("returns unavailable when code-server is not installed on host", async () => {
741
+ launcher.getSession.mockReturnValue({
742
+ sessionId: "s1",
743
+ state: "running",
744
+ cwd: "/repo",
745
+ });
746
+ mockResolveBinary.mockImplementation((name: string) => (name === "code-server" ? null : null));
747
+
748
+ const res = await app.request("/api/sessions/s1/editor/start", { method: "POST" });
749
+
750
+ expect(res.status).toBe(200);
751
+ const json = await res.json();
752
+ expect(json).toMatchObject({
753
+ available: false,
754
+ installed: false,
755
+ mode: "host",
756
+ });
757
+ expect(json.message).toContain("not installed");
758
+ });
759
+
760
+ it("starts host editor and returns a URL when code-server is available", async () => {
761
+ launcher.getSession.mockReturnValue({
762
+ sessionId: "s1",
763
+ state: "running",
764
+ cwd: "/repo/my app",
765
+ });
766
+ mockResolveBinary.mockImplementation((name: string) => (name === "code-server" ? "/usr/bin/code-server" : null));
767
+ // Mock fetch so the readiness poll resolves immediately instead of timing out
768
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok", { status: 200 }));
769
+
770
+ const res = await app.request("/api/sessions/s1/editor/start", { method: "POST" });
771
+
772
+ expect(res.status).toBe(200);
773
+ const json = await res.json();
774
+ expect(json).toMatchObject({
775
+ available: true,
776
+ installed: true,
777
+ mode: "host",
778
+ url: "http://localhost:13338?folder=%2Frepo%2Fmy%20app",
779
+ });
780
+ expect(execSync).toHaveBeenCalledWith(
781
+ expect.stringContaining("--bind-addr 127.0.0.1:13338"),
782
+ expect.objectContaining({ timeout: 10_000 }),
783
+ );
784
+ fetchSpy.mockRestore();
785
+ });
786
+
787
+ it("starts container editor and returns mapped host URL", async () => {
788
+ launcher.getSession.mockReturnValue({
789
+ sessionId: "s1",
790
+ state: "running",
791
+ cwd: "/repo",
792
+ containerId: "cid-1",
793
+ });
794
+ vi.spyOn(containerManager, "getContainer").mockReturnValue({
795
+ containerId: "cid-1",
796
+ name: "companion-s1",
797
+ image: "the-companion:latest",
798
+ portMappings: [{ containerPort: 13337, hostPort: 49152 }],
799
+ hostCwd: "/repo",
800
+ containerCwd: "/workspace",
801
+ state: "running",
802
+ });
803
+ vi.spyOn(containerManager, "hasBinaryInContainer").mockReturnValue(true);
804
+ vi.spyOn(containerManager, "isContainerAlive").mockReturnValue("running");
805
+ const execSpy = vi.spyOn(containerManager, "execInContainer").mockReturnValue("");
806
+ // Mock fetch so the readiness poll resolves immediately instead of timing out
807
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok", { status: 200 }));
808
+
809
+ const res = await app.request("/api/sessions/s1/editor/start", { method: "POST" });
810
+
811
+ expect(res.status).toBe(200);
812
+ const json = await res.json();
813
+ expect(json).toMatchObject({
814
+ available: true,
815
+ installed: true,
816
+ mode: "container",
817
+ url: "http://localhost:49152?folder=%2Fworkspace",
818
+ });
819
+ expect(execSpy).toHaveBeenCalledWith(
820
+ "cid-1",
821
+ expect.arrayContaining(["sh", "-lc"]),
822
+ 10_000,
823
+ );
824
+ fetchSpy.mockRestore();
825
+ });
826
+ });
827
+
828
+ describe("POST /api/sessions/:id/kill", () => {
829
+ it("returns ok when session is killed", async () => {
830
+ orchestrator.killSession.mockResolvedValue({ ok: true });
831
+
832
+ const res = await app.request("/api/sessions/s1/kill", { method: "POST" });
833
+
834
+ expect(res.status).toBe(200);
835
+ const json = await res.json();
836
+ expect(json).toEqual({ ok: true });
837
+ expect(orchestrator.killSession).toHaveBeenCalledWith("s1");
838
+ });
839
+
840
+ it("returns 404 when session not found", async () => {
841
+ orchestrator.killSession.mockResolvedValue({ ok: false });
842
+
843
+ const res = await app.request("/api/sessions/nonexistent/kill", { method: "POST" });
844
+
845
+ expect(res.status).toBe(404);
846
+ const json = await res.json();
847
+ expect(json).toEqual({ error: "Session not found or already exited" });
848
+ });
849
+ });
850
+
851
+ describe("POST /api/sessions/:id/relaunch", () => {
852
+ it("returns ok when session is relaunched", async () => {
853
+ orchestrator.relaunchSession.mockResolvedValue({ ok: true });
854
+
855
+ const res = await app.request("/api/sessions/s1/relaunch", { method: "POST" });
856
+
857
+ expect(res.status).toBe(200);
858
+ const json = await res.json();
859
+ expect(json).toEqual({ ok: true });
860
+ expect(orchestrator.relaunchSession).toHaveBeenCalledWith("s1");
861
+ });
862
+
863
+ it("returns 503 with error when container is missing", async () => {
864
+ orchestrator.relaunchSession.mockResolvedValue({
865
+ ok: false,
866
+ error: 'Container "companion-gone" was removed externally. Please create a new session.',
867
+ });
868
+
869
+ const res = await app.request("/api/sessions/s1/relaunch", { method: "POST" });
870
+
871
+ expect(res.status).toBe(503);
872
+ const json = await res.json();
873
+ expect(json.error).toContain("removed externally");
874
+ });
875
+
876
+ it("returns 404 when session not found via relaunch", async () => {
877
+ orchestrator.relaunchSession.mockResolvedValue({ ok: false, error: "Session not found" });
878
+
879
+ const res = await app.request("/api/sessions/nonexistent/relaunch", { method: "POST" });
880
+
881
+ expect(res.status).toBe(404);
882
+ const json = await res.json();
883
+ expect(json.error).toContain("Session not found");
884
+ });
885
+ });
886
+
887
+ describe("GET /api/sessions/:id/processes/system", () => {
888
+ it("parses macOS lsof LISTEN lines and returns dev servers", async () => {
889
+ launcher.getSession.mockReturnValue({
890
+ sessionId: "s1",
891
+ cwd: "/repo",
892
+ state: "running",
893
+ });
894
+
895
+ vi.mocked(execSync)
896
+ .mockReturnValueOnce(
897
+ [
898
+ "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME",
899
+ "node 12345 test 20u IPv6 0x123456789 0t0 TCP *:3000 (LISTEN)",
900
+ ].join("\n"),
901
+ )
902
+ .mockReturnValueOnce("node /repo/node_modules/vite/bin/vite.js --port 3000\n");
903
+
904
+ const res = await app.request("/api/sessions/s1/processes/system", { method: "GET" });
905
+
906
+ expect(res.status).toBe(200);
907
+ const json = await res.json();
908
+ expect(json).toEqual({
909
+ ok: true,
910
+ processes: [
911
+ {
912
+ pid: 12345,
913
+ command: "node",
914
+ fullCommand: "node /repo/node_modules/vite/bin/vite.js --port 3000",
915
+ ports: [3000],
916
+ },
917
+ ],
918
+ });
919
+ });
920
+
921
+ it("includes process cwd and best-effort start time when available", async () => {
922
+ launcher.getSession.mockReturnValue({
923
+ sessionId: "s1",
924
+ cwd: "/repo",
925
+ state: "running",
926
+ });
927
+
928
+ vi.mocked(execSync)
929
+ .mockReturnValueOnce(
930
+ [
931
+ "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME",
932
+ "bun 43210 test 20u IPv4 0x123456789 0t0 TCP *:3457 (LISTEN)",
933
+ ].join("\n"),
934
+ )
935
+ .mockReturnValueOnce("bun run dev\n")
936
+ .mockReturnValueOnce("p43210\nfcwd\nn/Users/test/project\n")
937
+ .mockReturnValueOnce("Mon Feb 23 10:00:00 2026\n");
938
+
939
+ const res = await app.request("/api/sessions/s1/processes/system", { method: "GET" });
940
+
941
+ expect(res.status).toBe(200);
942
+ const json = await res.json();
943
+ expect(json.ok).toBe(true);
944
+ expect(json.processes).toHaveLength(1);
945
+ expect(json.processes[0]).toMatchObject({
946
+ pid: 43210,
947
+ command: "bun",
948
+ fullCommand: "bun run dev",
949
+ cwd: "/Users/test/project",
950
+ ports: [3457],
951
+ });
952
+ expect(typeof json.processes[0].startedAt).toBe("number");
953
+ });
954
+ });
955
+
956
+ describe("DELETE /api/sessions/:id", () => {
957
+ // Route delegates to orchestrator.deleteSession — detailed cleanup logic
958
+ // (kill, container removal, worktree, etc.) is tested in session-orchestrator.test.ts
959
+
960
+ it("delegates to orchestrator and returns ok", async () => {
961
+ orchestrator.deleteSession.mockResolvedValue({ ok: true });
962
+
963
+ const res = await app.request("/api/sessions/s1", { method: "DELETE" });
964
+
965
+ expect(res.status).toBe(200);
966
+ const json = await res.json();
967
+ expect(json).toMatchObject({ ok: true });
968
+ expect(orchestrator.deleteSession).toHaveBeenCalledWith("s1");
969
+ });
970
+
971
+ it("returns worktree info from orchestrator result", async () => {
972
+ orchestrator.deleteSession.mockResolvedValue({
973
+ ok: true,
974
+ worktree: { cleaned: true, path: "/wt/feat" },
975
+ });
976
+
977
+ const res = await app.request("/api/sessions/s1", { method: "DELETE" });
978
+
979
+ expect(res.status).toBe(200);
980
+ const json = await res.json();
981
+ expect(json).toMatchObject({ ok: true });
982
+ expect(json.worktree).toMatchObject({ cleaned: true, path: "/wt/feat" });
983
+ });
984
+ });
985
+
986
+ describe("POST /api/sessions/:id/archive", () => {
987
+ // Route delegates to orchestrator.archiveSession — detailed cleanup logic
988
+ // (kill, container, worktree, Linear transition) is tested in session-orchestrator.test.ts
989
+
990
+ it("delegates to orchestrator and returns ok", async () => {
991
+ orchestrator.archiveSession.mockResolvedValue({ ok: true });
992
+
993
+ const res = await app.request("/api/sessions/s1/archive", {
994
+ method: "POST",
995
+ headers: { "Content-Type": "application/json" },
996
+ body: JSON.stringify({}),
997
+ });
998
+
999
+ expect(res.status).toBe(200);
1000
+ const json = await res.json();
1001
+ expect(json).toMatchObject({ ok: true });
1002
+ expect(orchestrator.archiveSession).toHaveBeenCalledWith("s1", {
1003
+ force: undefined,
1004
+ linearTransition: undefined,
1005
+ });
1006
+ });
1007
+ });
1008
+
1009
+ describe("POST /api/sessions/:id/archive — Linear transition", () => {
1010
+ // Route delegates to orchestrator.archiveSession — detailed Linear transition logic
1011
+ // is tested in session-orchestrator.test.ts. Route tests verify correct delegation.
1012
+
1013
+ it("passes linearTransition option to orchestrator", async () => {
1014
+ orchestrator.archiveSession.mockResolvedValue({ ok: true });
1015
+ const res = await app.request("/api/sessions/s1/archive", {
1016
+ method: "POST",
1017
+ headers: { "Content-Type": "application/json" },
1018
+ body: JSON.stringify({ linearTransition: "backlog" }),
1019
+ });
1020
+ expect(res.status).toBe(200);
1021
+ expect(orchestrator.archiveSession).toHaveBeenCalledWith("s1", {
1022
+ force: undefined,
1023
+ linearTransition: "backlog",
1024
+ });
1025
+ });
1026
+
1027
+ it("passes force option to orchestrator", async () => {
1028
+ orchestrator.archiveSession.mockResolvedValue({ ok: true });
1029
+ const res = await app.request("/api/sessions/s1/archive", {
1030
+ method: "POST",
1031
+ headers: { "Content-Type": "application/json" },
1032
+ body: JSON.stringify({ force: true, linearTransition: "configured" }),
1033
+ });
1034
+ expect(res.status).toBe(200);
1035
+ expect(orchestrator.archiveSession).toHaveBeenCalledWith("s1", {
1036
+ force: true,
1037
+ linearTransition: "configured",
1038
+ });
1039
+ });
1040
+
1041
+ it("returns linearTransition result from orchestrator", async () => {
1042
+ orchestrator.archiveSession.mockResolvedValue({
1043
+ ok: true,
1044
+ linearTransition: {
1045
+ ok: true,
1046
+ issue: { id: "i1", identifier: "ENG-1", stateName: "Backlog", stateType: "backlog" },
1047
+ },
1048
+ });
1049
+ const res = await app.request("/api/sessions/s1/archive", {
1050
+ method: "POST",
1051
+ headers: { "Content-Type": "application/json" },
1052
+ body: JSON.stringify({ linearTransition: "backlog" }),
1053
+ });
1054
+ expect(res.status).toBe(200);
1055
+ const json = await res.json();
1056
+ expect(json.ok).toBe(true);
1057
+ expect(json.linearTransition.ok).toBe(true);
1058
+ });
1059
+
1060
+ it("returns linearTransition failure from orchestrator", async () => {
1061
+ orchestrator.archiveSession.mockResolvedValue({
1062
+ ok: true,
1063
+ linearTransition: { ok: false, error: "Linear API error" },
1064
+ });
1065
+ const res = await app.request("/api/sessions/s1/archive", {
1066
+ method: "POST",
1067
+ headers: { "Content-Type": "application/json" },
1068
+ body: JSON.stringify({ linearTransition: "backlog" }),
1069
+ });
1070
+ expect(res.status).toBe(200);
1071
+ const json = await res.json();
1072
+ expect(json.ok).toBe(true);
1073
+ expect(json.linearTransition.ok).toBe(false);
1074
+ });
1075
+ });
1076
+
1077
+ describe("GET /api/sessions/:id/archive-info", () => {
1078
+ it("returns no linked issue when session has none", async () => {
1079
+ mockGetLinearIssue.mockReturnValue(undefined);
1080
+ const res = await app.request("/api/sessions/s1/archive-info", { method: "GET" });
1081
+ expect(res.status).toBe(200);
1082
+ const json = await res.json();
1083
+ expect(json).toEqual({ hasLinkedIssue: false, issueNotDone: false });
1084
+ });
1085
+
1086
+ it("returns issueNotDone false for completed issues", async () => {
1087
+ mockGetLinearIssue.mockReturnValue({
1088
+ id: "issue-1",
1089
+ identifier: "ENG-42",
1090
+ title: "Done issue",
1091
+ description: "",
1092
+ url: "",
1093
+ branchName: "",
1094
+ priorityLabel: "",
1095
+ stateName: "Done",
1096
+ stateType: "completed",
1097
+ teamName: "Engineering",
1098
+ teamKey: "ENG",
1099
+ teamId: "team-1",
1100
+ });
1101
+ const res = await app.request("/api/sessions/s1/archive-info", { method: "GET" });
1102
+ expect(res.status).toBe(200);
1103
+ const json = await res.json();
1104
+ expect(json.hasLinkedIssue).toBe(true);
1105
+ expect(json.issueNotDone).toBe(false);
1106
+ });
1107
+
1108
+ it("returns transition options for non-done issues", async () => {
1109
+ mockGetLinearIssue.mockReturnValue({
1110
+ id: "issue-1",
1111
+ identifier: "ENG-42",
1112
+ title: "In progress issue",
1113
+ description: "",
1114
+ url: "",
1115
+ branchName: "",
1116
+ priorityLabel: "",
1117
+ stateName: "In Progress",
1118
+ stateType: "started",
1119
+ teamName: "Engineering",
1120
+ teamKey: "ENG",
1121
+ teamId: "team-1",
1122
+ });
1123
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
1124
+ anthropicApiKey: "",
1125
+ anthropicModel: "claude-sonnet-4-6",
1126
+ linearApiKey: "lin_test_key",
1127
+ linearAutoTransition: false,
1128
+ linearAutoTransitionStateId: "",
1129
+ linearAutoTransitionStateName: "",
1130
+ linearArchiveTransition: true,
1131
+ linearArchiveTransitionStateId: "state-custom",
1132
+ linearArchiveTransitionStateName: "Review",
1133
+ linearOAuthClientId: "",
1134
+ linearOAuthClientSecret: "",
1135
+ linearOAuthWebhookSecret: "",
1136
+ linearOAuthAccessToken: "",
1137
+ linearOAuthRefreshToken: "",
1138
+ claudeCodeOAuthToken: "",
1139
+ openaiApiKey: "",
1140
+ onboardingCompleted: false,
1141
+ aiValidationEnabled: false,
1142
+ aiValidationAutoApprove: true,
1143
+ aiValidationAutoDeny: false,
1144
+ publicUrl: "",
1145
+ updateChannel: "stable",
1146
+ dockerAutoUpdate: false,
1147
+ updatedAt: 0,
1148
+ });
1149
+ const res = await app.request("/api/sessions/s1/archive-info", { method: "GET" });
1150
+ expect(res.status).toBe(200);
1151
+ const json = await res.json();
1152
+ expect(json.hasLinkedIssue).toBe(true);
1153
+ expect(json.issueNotDone).toBe(true);
1154
+ expect(json.hasBacklogState).toBe(true);
1155
+ expect(json.archiveTransitionConfigured).toBe(true);
1156
+ expect(json.archiveTransitionStateName).toBe("Review");
1157
+ expect(json.issue.identifier).toBe("ENG-42");
1158
+ });
1159
+ });
1160
+
1161
+ describe("POST /api/sessions/:id/unarchive", () => {
1162
+ it("delegates to orchestrator and returns ok", async () => {
1163
+ orchestrator.unarchiveSession.mockReturnValue({ ok: true });
1164
+
1165
+ const res = await app.request("/api/sessions/s1/unarchive", { method: "POST" });
1166
+
1167
+ expect(res.status).toBe(200);
1168
+ const json = await res.json();
1169
+ expect(json).toEqual({ ok: true });
1170
+ expect(orchestrator.unarchiveSession).toHaveBeenCalledWith("s1");
1171
+ });
1172
+ });
1173
+
1174
+ // ─── Environments ────────────────────────────────────────────────────────────
1175
+
1176
+ describe("GET /api/envs", () => {
1177
+ it("returns the list of environments", async () => {
1178
+ const envs = [
1179
+ { name: "Dev", slug: "dev", variables: { A: "1" }, createdAt: 1, updatedAt: 1 },
1180
+ ];
1181
+ vi.mocked(envManager.listEnvs).mockReturnValue(envs);
1182
+
1183
+ const res = await app.request("/api/envs", { method: "GET" });
1184
+
1185
+ expect(res.status).toBe(200);
1186
+ const json = await res.json();
1187
+ expect(json).toEqual(envs);
1188
+ });
1189
+ });
1190
+
1191
+ describe("POST /api/envs", () => {
1192
+ it("creates an environment and returns 201", async () => {
1193
+ const created = {
1194
+ name: "Staging",
1195
+ slug: "staging",
1196
+ variables: { HOST: "staging.example.com" },
1197
+ createdAt: 1000,
1198
+ updatedAt: 1000,
1199
+ };
1200
+ vi.mocked(envManager.createEnv).mockReturnValue(created);
1201
+
1202
+ const res = await app.request("/api/envs", {
1203
+ method: "POST",
1204
+ headers: { "Content-Type": "application/json" },
1205
+ body: JSON.stringify({ name: "Staging", variables: { HOST: "staging.example.com" } }),
1206
+ });
1207
+
1208
+ expect(res.status).toBe(201);
1209
+ const json = await res.json();
1210
+ expect(json).toEqual(created);
1211
+ expect(envManager.createEnv).toHaveBeenCalledWith(
1212
+ "Staging",
1213
+ { HOST: "staging.example.com" },
1214
+ );
1215
+ });
1216
+
1217
+ it("returns 400 when createEnv throws", async () => {
1218
+ vi.mocked(envManager.createEnv).mockImplementation(() => {
1219
+ throw new Error("Environment name is required");
1220
+ });
1221
+
1222
+ const res = await app.request("/api/envs", {
1223
+ method: "POST",
1224
+ headers: { "Content-Type": "application/json" },
1225
+ body: JSON.stringify({}),
1226
+ });
1227
+
1228
+ expect(res.status).toBe(400);
1229
+ const json = await res.json();
1230
+ expect(json).toEqual({ error: "Environment name is required" });
1231
+ });
1232
+ });
1233
+
1234
+ describe("PUT /api/envs/:slug", () => {
1235
+ it("updates an existing environment", async () => {
1236
+ const updated = {
1237
+ name: "Production v2",
1238
+ slug: "production-v2",
1239
+ variables: { KEY: "new-value" },
1240
+ createdAt: 1000,
1241
+ updatedAt: 2000,
1242
+ };
1243
+ vi.mocked(envManager.updateEnv).mockReturnValue(updated);
1244
+
1245
+ const res = await app.request("/api/envs/production", {
1246
+ method: "PUT",
1247
+ headers: { "Content-Type": "application/json" },
1248
+ body: JSON.stringify({ name: "Production v2", variables: { KEY: "new-value" } }),
1249
+ });
1250
+
1251
+ expect(res.status).toBe(200);
1252
+ const json = await res.json();
1253
+ expect(json).toEqual(updated);
1254
+ expect(envManager.updateEnv).toHaveBeenCalledWith("production", {
1255
+ name: "Production v2",
1256
+ variables: { KEY: "new-value" },
1257
+ });
1258
+ });
1259
+ });
1260
+
1261
+ describe("DELETE /api/envs/:slug", () => {
1262
+ it("deletes an existing environment", async () => {
1263
+ vi.mocked(envManager.deleteEnv).mockReturnValue(true);
1264
+
1265
+ const res = await app.request("/api/envs/staging", { method: "DELETE" });
1266
+
1267
+ expect(res.status).toBe(200);
1268
+ const json = await res.json();
1269
+ expect(json).toEqual({ ok: true });
1270
+ expect(envManager.deleteEnv).toHaveBeenCalledWith("staging");
1271
+ });
1272
+
1273
+ it("returns 404 when environment not found", async () => {
1274
+ vi.mocked(envManager.deleteEnv).mockReturnValue(false);
1275
+
1276
+ const res = await app.request("/api/envs/nonexistent", { method: "DELETE" });
1277
+
1278
+ expect(res.status).toBe(404);
1279
+ const json = await res.json();
1280
+ expect(json).toEqual({ error: "Environment not found" });
1281
+ });
1282
+ });
1283
+
1284
+ describe("Saved prompts API", () => {
1285
+ it("lists prompts with cwd filter", async () => {
1286
+ // Confirms route passes cwd/scope filter through to prompt manager.
1287
+ const prompts = [
1288
+ {
1289
+ id: "p1",
1290
+ name: "Review",
1291
+ content: "Review this PR",
1292
+ scope: "global" as const,
1293
+ createdAt: 1,
1294
+ updatedAt: 2,
1295
+ },
1296
+ ];
1297
+ vi.mocked(promptManager.listPrompts).mockReturnValue(prompts);
1298
+
1299
+ const res = await app.request("/api/prompts?cwd=%2Frepo", { method: "GET" });
1300
+ expect(res.status).toBe(200);
1301
+ const json = await res.json();
1302
+ expect(json).toEqual(prompts);
1303
+ expect(promptManager.listPrompts).toHaveBeenCalledWith({ cwd: "/repo", scope: undefined });
1304
+ });
1305
+
1306
+ it("creates a prompt with legacy cwd", async () => {
1307
+ // Confirms payload mapping for prompt creation including project cwd.
1308
+ const created = {
1309
+ id: "p1",
1310
+ name: "Review",
1311
+ content: "Review this PR",
1312
+ scope: "project" as const,
1313
+ projectPath: "/repo",
1314
+ projectPaths: ["/repo"],
1315
+ createdAt: 1,
1316
+ updatedAt: 1,
1317
+ };
1318
+ vi.mocked(promptManager.createPrompt).mockReturnValue(created);
1319
+
1320
+ const res = await app.request("/api/prompts", {
1321
+ method: "POST",
1322
+ headers: { "Content-Type": "application/json" },
1323
+ body: JSON.stringify({
1324
+ name: "Review",
1325
+ content: "Review this PR",
1326
+ scope: "project",
1327
+ cwd: "/repo",
1328
+ }),
1329
+ });
1330
+
1331
+ expect(res.status).toBe(201);
1332
+ expect(promptManager.createPrompt).toHaveBeenCalledWith(
1333
+ "Review",
1334
+ "Review this PR",
1335
+ "project",
1336
+ "/repo",
1337
+ undefined,
1338
+ );
1339
+ });
1340
+
1341
+ it("creates a prompt with projectPaths", async () => {
1342
+ // Confirms projectPaths array is forwarded to prompt manager.
1343
+ const created = {
1344
+ id: "p2",
1345
+ name: "Multi",
1346
+ content: "Multi-project prompt",
1347
+ scope: "project" as const,
1348
+ projectPath: "/repo-a",
1349
+ projectPaths: ["/repo-a", "/repo-b"],
1350
+ createdAt: 1,
1351
+ updatedAt: 1,
1352
+ };
1353
+ vi.mocked(promptManager.createPrompt).mockReturnValue(created);
1354
+
1355
+ const res = await app.request("/api/prompts", {
1356
+ method: "POST",
1357
+ headers: { "Content-Type": "application/json" },
1358
+ body: JSON.stringify({
1359
+ name: "Multi",
1360
+ content: "Multi-project prompt",
1361
+ scope: "project",
1362
+ projectPaths: ["/repo-a", "/repo-b"],
1363
+ }),
1364
+ });
1365
+
1366
+ expect(res.status).toBe(201);
1367
+ expect(promptManager.createPrompt).toHaveBeenCalledWith(
1368
+ "Multi",
1369
+ "Multi-project prompt",
1370
+ "project",
1371
+ undefined,
1372
+ ["/repo-a", "/repo-b"],
1373
+ );
1374
+ });
1375
+
1376
+ it("updates a prompt", async () => {
1377
+ // Confirms update fields are forwarded verbatim.
1378
+ vi.mocked(promptManager.updatePrompt).mockReturnValue({
1379
+ id: "p1",
1380
+ name: "Updated",
1381
+ content: "Updated content",
1382
+ scope: "global",
1383
+ createdAt: 1,
1384
+ updatedAt: 2,
1385
+ });
1386
+
1387
+ const res = await app.request("/api/prompts/p1", {
1388
+ method: "PUT",
1389
+ headers: { "Content-Type": "application/json" },
1390
+ body: JSON.stringify({ name: "Updated", content: "Updated content" }),
1391
+ });
1392
+ expect(res.status).toBe(200);
1393
+ expect(promptManager.updatePrompt).toHaveBeenCalledWith("p1", {
1394
+ name: "Updated",
1395
+ content: "Updated content",
1396
+ scope: undefined,
1397
+ projectPaths: undefined,
1398
+ });
1399
+ });
1400
+
1401
+ it("updates a prompt scope and projectPaths", async () => {
1402
+ // Confirms scope and projectPaths updates are forwarded.
1403
+ vi.mocked(promptManager.updatePrompt).mockReturnValue({
1404
+ id: "p1",
1405
+ name: "Updated",
1406
+ content: "Updated content",
1407
+ scope: "project",
1408
+ projectPath: "/repo",
1409
+ projectPaths: ["/repo"],
1410
+ createdAt: 1,
1411
+ updatedAt: 2,
1412
+ });
1413
+
1414
+ const res = await app.request("/api/prompts/p1", {
1415
+ method: "PUT",
1416
+ headers: { "Content-Type": "application/json" },
1417
+ body: JSON.stringify({ scope: "project", projectPaths: ["/repo"] }),
1418
+ });
1419
+ expect(res.status).toBe(200);
1420
+ expect(promptManager.updatePrompt).toHaveBeenCalledWith("p1", {
1421
+ name: undefined,
1422
+ content: undefined,
1423
+ scope: "project",
1424
+ projectPaths: ["/repo"],
1425
+ });
1426
+ });
1427
+
1428
+ it("deletes a prompt", async () => {
1429
+ // Confirms delete endpoint calls manager and returns ok shape.
1430
+ vi.mocked(promptManager.deletePrompt).mockReturnValue(true);
1431
+
1432
+ const res = await app.request("/api/prompts/p1", { method: "DELETE" });
1433
+ expect(res.status).toBe(200);
1434
+ const json = await res.json();
1435
+ expect(json).toEqual({ ok: true });
1436
+ expect(promptManager.deletePrompt).toHaveBeenCalledWith("p1");
1437
+ });
1438
+ });
1439
+
1440
+ // ─── Image Pull Manager API ──────────────────────────────────────────────────
1441
+
1442
+ describe("GET /api/images/:tag/status", () => {
1443
+ it("returns the pull state for an image", async () => {
1444
+ mockImagePullGetState.mockReturnValueOnce({
1445
+ image: "the-companion:latest",
1446
+ status: "ready",
1447
+ progress: [],
1448
+ });
1449
+
1450
+ const res = await app.request("/api/images/the-companion%3Alatest/status");
1451
+ expect(res.status).toBe(200);
1452
+ const json = await res.json();
1453
+ expect(json.image).toBe("the-companion:latest");
1454
+ expect(json.status).toBe("ready");
1455
+ });
1456
+ });
1457
+
1458
+ describe("POST /api/images/:tag/pull", () => {
1459
+ it("triggers a pull and returns the current state", async () => {
1460
+ vi.spyOn(containerManager, "checkDocker").mockReturnValue(true);
1461
+ mockImagePullGetState.mockReturnValueOnce({
1462
+ image: "the-companion:latest",
1463
+ status: "pulling",
1464
+ progress: [],
1465
+ startedAt: Date.now(),
1466
+ });
1467
+
1468
+ const res = await app.request("/api/images/the-companion%3Alatest/pull", {
1469
+ method: "POST",
1470
+ });
1471
+ expect(res.status).toBe(200);
1472
+ const json = await res.json();
1473
+ expect(json.ok).toBe(true);
1474
+ expect(mockImagePullPull).toHaveBeenCalledWith("the-companion:latest");
1475
+ });
1476
+
1477
+ it("returns 503 when Docker is not available", async () => {
1478
+ vi.spyOn(containerManager, "checkDocker").mockReturnValue(false);
1479
+
1480
+ const res = await app.request("/api/images/the-companion%3Alatest/pull", {
1481
+ method: "POST",
1482
+ });
1483
+ expect(res.status).toBe(503);
1484
+ const json = await res.json();
1485
+ expect(json.error).toContain("Docker is not available");
1486
+ });
1487
+ });
1488
+
1489
+ // ─── Settings ────────────────────────────────────────────────────────────────
1490
+
1491
+ describe("GET /api/settings", () => {
1492
+ it("returns settings status without exposing the key", async () => {
1493
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
1494
+ anthropicApiKey: "or-secret",
1495
+ anthropicModel: "claude-sonnet-4-6",
1496
+ linearApiKey: "",
1497
+ linearAutoTransition: false,
1498
+ linearAutoTransitionStateId: "",
1499
+ linearAutoTransitionStateName: "",
1500
+ linearArchiveTransition: false,
1501
+ linearArchiveTransitionStateId: "",
1502
+ linearArchiveTransitionStateName: "",
1503
+ linearOAuthClientId: "",
1504
+ linearOAuthClientSecret: "",
1505
+ linearOAuthWebhookSecret: "",
1506
+ linearOAuthAccessToken: "",
1507
+ linearOAuthRefreshToken: "",
1508
+ claudeCodeOAuthToken: "",
1509
+ openaiApiKey: "",
1510
+ onboardingCompleted: false,
1511
+ aiValidationEnabled: false,
1512
+ aiValidationAutoApprove: true,
1513
+ aiValidationAutoDeny: false,
1514
+ publicUrl: "",
1515
+ updateChannel: "stable",
1516
+ dockerAutoUpdate: false,
1517
+ updatedAt: 123,
1518
+ });
1519
+
1520
+ const res = await app.request("/api/settings", { method: "GET" });
1521
+
1522
+ expect(res.status).toBe(200);
1523
+ const json = await res.json();
1524
+ expect(json).toEqual({
1525
+ anthropicApiKeyConfigured: true,
1526
+ anthropicModel: "claude-sonnet-4-6",
1527
+ claudeCodeOAuthTokenConfigured: false,
1528
+ openaiApiKeyConfigured: false,
1529
+ codexDeviceAuthConfigured: false,
1530
+ onboardingCompleted: false,
1531
+ linearApiKeyConfigured: false,
1532
+ linearConnectionCount: 0,
1533
+ linearAutoTransition: false,
1534
+ linearAutoTransitionStateName: "",
1535
+ linearArchiveTransition: false,
1536
+ linearArchiveTransitionStateName: "",
1537
+ linearOAuthConfigured: false,
1538
+ linearOAuthCredentialsSaved: false,
1539
+ aiValidationEnabled: false,
1540
+ aiValidationAutoApprove: true,
1541
+ aiValidationAutoDeny: false,
1542
+ publicUrl: "",
1543
+ updateChannel: "stable",
1544
+ dockerAutoUpdate: false,
1545
+ });
1546
+ });
1547
+
1548
+ it("reports key as not configured when empty", async () => {
1549
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
1550
+ anthropicApiKey: "",
1551
+ anthropicModel: "openai/gpt-4o-mini",
1552
+ linearApiKey: "lin_api_123",
1553
+ linearAutoTransition: false,
1554
+ linearAutoTransitionStateId: "",
1555
+ linearAutoTransitionStateName: "",
1556
+ linearArchiveTransition: false,
1557
+ linearArchiveTransitionStateId: "",
1558
+ linearArchiveTransitionStateName: "",
1559
+ linearOAuthClientId: "",
1560
+ linearOAuthClientSecret: "",
1561
+ linearOAuthWebhookSecret: "",
1562
+ linearOAuthAccessToken: "",
1563
+ linearOAuthRefreshToken: "",
1564
+ claudeCodeOAuthToken: "",
1565
+ openaiApiKey: "",
1566
+ onboardingCompleted: false,
1567
+ aiValidationEnabled: false,
1568
+ aiValidationAutoApprove: true,
1569
+ aiValidationAutoDeny: false,
1570
+ publicUrl: "",
1571
+ updateChannel: "stable",
1572
+ dockerAutoUpdate: false,
1573
+ updatedAt: 123,
1574
+ });
1575
+
1576
+ const res = await app.request("/api/settings", { method: "GET" });
1577
+
1578
+ expect(res.status).toBe(200);
1579
+ const json = await res.json();
1580
+ expect(json).toEqual({
1581
+ anthropicApiKeyConfigured: false,
1582
+ anthropicModel: "openai/gpt-4o-mini",
1583
+ claudeCodeOAuthTokenConfigured: false,
1584
+ openaiApiKeyConfigured: false,
1585
+ codexDeviceAuthConfigured: false,
1586
+ onboardingCompleted: false,
1587
+ linearApiKeyConfigured: true,
1588
+ linearConnectionCount: 0,
1589
+ linearAutoTransition: false,
1590
+ linearAutoTransitionStateName: "",
1591
+ linearArchiveTransition: false,
1592
+ linearArchiveTransitionStateName: "",
1593
+ linearOAuthConfigured: false,
1594
+ linearOAuthCredentialsSaved: false,
1595
+ aiValidationEnabled: false,
1596
+ aiValidationAutoApprove: true,
1597
+ aiValidationAutoDeny: false,
1598
+ publicUrl: "",
1599
+ updateChannel: "stable",
1600
+ dockerAutoUpdate: false,
1601
+ });
1602
+ });
1603
+
1604
+ // Verifies publicUrl is included in GET response when set to a non-empty value
1605
+ it("includes publicUrl in response when configured", async () => {
1606
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
1607
+ anthropicApiKey: "",
1608
+ anthropicModel: "claude-sonnet-4-6",
1609
+ linearApiKey: "",
1610
+ linearAutoTransition: false,
1611
+ linearAutoTransitionStateId: "",
1612
+ linearAutoTransitionStateName: "",
1613
+ linearArchiveTransition: false,
1614
+ linearArchiveTransitionStateId: "",
1615
+ linearArchiveTransitionStateName: "",
1616
+ linearOAuthClientId: "",
1617
+ linearOAuthClientSecret: "",
1618
+ linearOAuthWebhookSecret: "",
1619
+ linearOAuthAccessToken: "",
1620
+ linearOAuthRefreshToken: "",
1621
+ claudeCodeOAuthToken: "",
1622
+ openaiApiKey: "",
1623
+ onboardingCompleted: false,
1624
+ aiValidationEnabled: false,
1625
+ aiValidationAutoApprove: true,
1626
+ aiValidationAutoDeny: false,
1627
+ publicUrl: "https://example.com",
1628
+ updateChannel: "stable",
1629
+ dockerAutoUpdate: false,
1630
+ updatedAt: 100,
1631
+ });
1632
+
1633
+ const res = await app.request("/api/settings", { method: "GET" });
1634
+
1635
+ expect(res.status).toBe(200);
1636
+ const json = await res.json();
1637
+ expect(json.publicUrl).toBe("https://example.com");
1638
+ });
1639
+ });
1640
+
1641
+ describe("PUT /api/settings", () => {
1642
+ it("updates settings", async () => {
1643
+ vi.mocked(settingsManager.updateSettings).mockReturnValue({
1644
+ anthropicApiKey: "new-key",
1645
+ anthropicModel: "claude-sonnet-4-6",
1646
+ linearApiKey: "",
1647
+ linearAutoTransition: false,
1648
+ linearAutoTransitionStateId: "",
1649
+ linearAutoTransitionStateName: "",
1650
+ linearArchiveTransition: false,
1651
+ linearArchiveTransitionStateId: "",
1652
+ linearArchiveTransitionStateName: "",
1653
+ linearOAuthClientId: "",
1654
+ linearOAuthClientSecret: "",
1655
+ linearOAuthWebhookSecret: "",
1656
+ linearOAuthAccessToken: "",
1657
+ linearOAuthRefreshToken: "",
1658
+ claudeCodeOAuthToken: "",
1659
+ openaiApiKey: "",
1660
+ onboardingCompleted: false,
1661
+ aiValidationEnabled: false,
1662
+ aiValidationAutoApprove: true,
1663
+ aiValidationAutoDeny: false,
1664
+ publicUrl: "",
1665
+ updateChannel: "stable",
1666
+ dockerAutoUpdate: false,
1667
+ updatedAt: 456,
1668
+ });
1669
+
1670
+ const res = await app.request("/api/settings", {
1671
+ method: "PUT",
1672
+ headers: { "Content-Type": "application/json" },
1673
+ body: JSON.stringify({ anthropicApiKey: "new-key" }),
1674
+ });
1675
+
1676
+ expect(res.status).toBe(200);
1677
+ expect(settingsManager.updateSettings).toHaveBeenCalledWith({
1678
+ anthropicApiKey: "new-key",
1679
+ anthropicModel: undefined,
1680
+ linearApiKey: undefined,
1681
+ linearAutoTransition: undefined,
1682
+ linearAutoTransitionStateId: undefined,
1683
+ linearAutoTransitionStateName: undefined,
1684
+ linearArchiveTransition: undefined,
1685
+ linearArchiveTransitionStateId: undefined,
1686
+ linearArchiveTransitionStateName: undefined,
1687
+ linearOAuthClientId: undefined,
1688
+ linearOAuthClientSecret: undefined,
1689
+ linearOAuthWebhookSecret: undefined,
1690
+ aiValidationEnabled: undefined,
1691
+ aiValidationAutoApprove: undefined,
1692
+ aiValidationAutoDeny: undefined,
1693
+ updateChannel: undefined,
1694
+ });
1695
+ const json = await res.json();
1696
+ expect(json).toEqual({
1697
+ anthropicApiKeyConfigured: true,
1698
+ anthropicModel: "claude-sonnet-4-6",
1699
+ claudeCodeOAuthTokenConfigured: false,
1700
+ openaiApiKeyConfigured: false,
1701
+ codexDeviceAuthConfigured: false,
1702
+ onboardingCompleted: false,
1703
+ linearApiKeyConfigured: false,
1704
+ linearConnectionCount: 0,
1705
+ linearAutoTransition: false,
1706
+ linearAutoTransitionStateName: "",
1707
+ linearArchiveTransition: false,
1708
+ linearArchiveTransitionStateName: "",
1709
+ linearOAuthConfigured: false,
1710
+ linearOAuthCredentialsSaved: false,
1711
+ aiValidationEnabled: false,
1712
+ aiValidationAutoApprove: true,
1713
+ aiValidationAutoDeny: false,
1714
+ publicUrl: "",
1715
+ updateChannel: "stable",
1716
+ dockerAutoUpdate: false,
1717
+ });
1718
+ });
1719
+
1720
+ it("trims key and falls back to default model for blank value", async () => {
1721
+ vi.mocked(settingsManager.updateSettings).mockReturnValue({
1722
+ anthropicApiKey: "trimmed-key",
1723
+ anthropicModel: "claude-sonnet-4-6",
1724
+ linearApiKey: "lin_api_trimmed",
1725
+ linearAutoTransition: false,
1726
+ linearAutoTransitionStateId: "",
1727
+ linearAutoTransitionStateName: "",
1728
+ linearArchiveTransition: false,
1729
+ linearArchiveTransitionStateId: "",
1730
+ linearArchiveTransitionStateName: "",
1731
+ linearOAuthClientId: "",
1732
+ linearOAuthClientSecret: "",
1733
+ linearOAuthWebhookSecret: "",
1734
+ linearOAuthAccessToken: "",
1735
+ linearOAuthRefreshToken: "",
1736
+ claudeCodeOAuthToken: "",
1737
+ openaiApiKey: "",
1738
+ onboardingCompleted: false,
1739
+ aiValidationEnabled: false,
1740
+ aiValidationAutoApprove: true,
1741
+ aiValidationAutoDeny: false,
1742
+ publicUrl: "",
1743
+ updateChannel: "stable",
1744
+ dockerAutoUpdate: false,
1745
+ updatedAt: 789,
1746
+ });
1747
+
1748
+ const res = await app.request("/api/settings", {
1749
+ method: "PUT",
1750
+ headers: { "Content-Type": "application/json" },
1751
+ body: JSON.stringify({ anthropicApiKey: " trimmed-key ", anthropicModel: " ", linearApiKey: " lin_api_trimmed " }),
1752
+ });
1753
+
1754
+ expect(res.status).toBe(200);
1755
+ expect(settingsManager.updateSettings).toHaveBeenCalledWith({
1756
+ anthropicApiKey: "trimmed-key",
1757
+ anthropicModel: "claude-sonnet-4-6",
1758
+ linearApiKey: "lin_api_trimmed",
1759
+ linearAutoTransition: undefined,
1760
+ linearAutoTransitionStateId: undefined,
1761
+ linearAutoTransitionStateName: undefined,
1762
+ });
1763
+ });
1764
+
1765
+ it("updates only model without overriding key", async () => {
1766
+ vi.mocked(settingsManager.updateSettings).mockReturnValue({
1767
+ anthropicApiKey: "existing-key",
1768
+ anthropicModel: "openai/gpt-4o-mini",
1769
+ linearApiKey: "lin_api_existing",
1770
+ linearAutoTransition: false,
1771
+ linearAutoTransitionStateId: "",
1772
+ linearAutoTransitionStateName: "",
1773
+ linearArchiveTransition: false,
1774
+ linearArchiveTransitionStateId: "",
1775
+ linearArchiveTransitionStateName: "",
1776
+ linearOAuthClientId: "",
1777
+ linearOAuthClientSecret: "",
1778
+ linearOAuthWebhookSecret: "",
1779
+ linearOAuthAccessToken: "",
1780
+ linearOAuthRefreshToken: "",
1781
+ claudeCodeOAuthToken: "",
1782
+ openaiApiKey: "",
1783
+ onboardingCompleted: false,
1784
+ aiValidationEnabled: false,
1785
+ aiValidationAutoApprove: true,
1786
+ aiValidationAutoDeny: false,
1787
+ publicUrl: "",
1788
+ updateChannel: "stable",
1789
+ dockerAutoUpdate: false,
1790
+ updatedAt: 999,
1791
+ });
1792
+
1793
+ const res = await app.request("/api/settings", {
1794
+ method: "PUT",
1795
+ headers: { "Content-Type": "application/json" },
1796
+ body: JSON.stringify({ anthropicModel: "openai/gpt-4o-mini" }),
1797
+ });
1798
+
1799
+ expect(res.status).toBe(200);
1800
+ expect(settingsManager.updateSettings).toHaveBeenCalledWith({
1801
+ anthropicApiKey: undefined,
1802
+ anthropicModel: "openai/gpt-4o-mini",
1803
+ linearApiKey: undefined,
1804
+ linearAutoTransition: undefined,
1805
+ linearAutoTransitionStateId: undefined,
1806
+ linearAutoTransitionStateName: undefined,
1807
+ });
1808
+ });
1809
+
1810
+ it("returns 400 for non-string linear key", async () => {
1811
+ const res = await app.request("/api/settings", {
1812
+ method: "PUT",
1813
+ headers: { "Content-Type": "application/json" },
1814
+ body: JSON.stringify({ linearApiKey: 123 }),
1815
+ });
1816
+
1817
+ expect(res.status).toBe(400);
1818
+ const json = await res.json();
1819
+ expect(json).toEqual({ error: "linearApiKey must be a string" });
1820
+ });
1821
+
1822
+ it("returns 400 for non-string model", async () => {
1823
+ const res = await app.request("/api/settings", {
1824
+ method: "PUT",
1825
+ headers: { "Content-Type": "application/json" },
1826
+ body: JSON.stringify({ anthropicApiKey: "new-key", anthropicModel: 123 }),
1827
+ });
1828
+
1829
+ expect(res.status).toBe(400);
1830
+ const json = await res.json();
1831
+ expect(json).toEqual({ error: "anthropicModel must be a string" });
1832
+ });
1833
+
1834
+ it("returns 400 for non-string key", async () => {
1835
+ const res = await app.request("/api/settings", {
1836
+ method: "PUT",
1837
+ headers: { "Content-Type": "application/json" },
1838
+ body: JSON.stringify({ anthropicApiKey: 123 }),
1839
+ });
1840
+
1841
+ expect(res.status).toBe(400);
1842
+ const json = await res.json();
1843
+ expect(json).toEqual({ error: "anthropicApiKey must be a string" });
1844
+ });
1845
+
1846
+ // Rejects invalid updateChannel values that aren't "stable" or "prerelease"
1847
+ it("returns 400 for invalid updateChannel value", async () => {
1848
+ const res = await app.request("/api/settings", {
1849
+ method: "PUT",
1850
+ headers: { "Content-Type": "application/json" },
1851
+ body: JSON.stringify({ updateChannel: "nightly" }),
1852
+ });
1853
+
1854
+ expect(res.status).toBe(400);
1855
+ const json = await res.json();
1856
+ expect(json).toEqual({ error: "updateChannel must be 'stable' or 'prerelease'" });
1857
+ });
1858
+
1859
+ // Verifies that PUT /api/settings accepts a publicUrl string and passes
1860
+ // it (trimmed, trailing-slash-stripped) to updateSettings
1861
+ it("accepts and saves publicUrl string", async () => {
1862
+ vi.mocked(settingsManager.updateSettings).mockReturnValue({
1863
+ anthropicApiKey: "",
1864
+ anthropicModel: "claude-sonnet-4-6",
1865
+ linearApiKey: "",
1866
+ linearAutoTransition: false,
1867
+ linearAutoTransitionStateId: "",
1868
+ linearAutoTransitionStateName: "",
1869
+ linearArchiveTransition: false,
1870
+ linearArchiveTransitionStateId: "",
1871
+ linearArchiveTransitionStateName: "",
1872
+ linearOAuthClientId: "",
1873
+ linearOAuthClientSecret: "",
1874
+ linearOAuthWebhookSecret: "",
1875
+ linearOAuthAccessToken: "",
1876
+ linearOAuthRefreshToken: "",
1877
+ claudeCodeOAuthToken: "",
1878
+ openaiApiKey: "",
1879
+ onboardingCompleted: false,
1880
+ aiValidationEnabled: false,
1881
+ aiValidationAutoApprove: true,
1882
+ aiValidationAutoDeny: false,
1883
+ publicUrl: "https://my-server.com",
1884
+ updateChannel: "stable",
1885
+ dockerAutoUpdate: false,
1886
+ updatedAt: 500,
1887
+ });
1888
+
1889
+ const res = await app.request("/api/settings", {
1890
+ method: "PUT",
1891
+ headers: { "Content-Type": "application/json" },
1892
+ body: JSON.stringify({ publicUrl: " https://my-server.com/// " }),
1893
+ });
1894
+
1895
+ expect(res.status).toBe(200);
1896
+ // The route trims whitespace and strips trailing slashes before calling updateSettings
1897
+ expect(settingsManager.updateSettings).toHaveBeenCalledWith(
1898
+ expect.objectContaining({
1899
+ publicUrl: "https://my-server.com",
1900
+ }),
1901
+ );
1902
+ const json = await res.json();
1903
+ expect(json.publicUrl).toBe("https://my-server.com");
1904
+ });
1905
+
1906
+ // Rejects non-string publicUrl values with a 400 error
1907
+ it("returns 400 for non-string publicUrl", async () => {
1908
+ const res = await app.request("/api/settings", {
1909
+ method: "PUT",
1910
+ headers: { "Content-Type": "application/json" },
1911
+ body: JSON.stringify({ publicUrl: 123 }),
1912
+ });
1913
+
1914
+ expect(res.status).toBe(400);
1915
+ const json = await res.json();
1916
+ expect(json).toEqual({ error: "publicUrl must be a string" });
1917
+ });
1918
+
1919
+ // Rejects publicUrl values that are not valid http/https URLs
1920
+ it("returns 400 for publicUrl with invalid scheme", async () => {
1921
+ const res = await app.request("/api/settings", {
1922
+ method: "PUT",
1923
+ headers: { "Content-Type": "application/json" },
1924
+ body: JSON.stringify({ publicUrl: "ftp://bad-scheme.com" }),
1925
+ });
1926
+
1927
+ expect(res.status).toBe(400);
1928
+ const json = await res.json();
1929
+ expect(json).toEqual({ error: "publicUrl must be a valid http/https URL" });
1930
+ });
1931
+
1932
+ it("returns 400 when no settings fields are provided", async () => {
1933
+ const res = await app.request("/api/settings", {
1934
+ method: "PUT",
1935
+ headers: { "Content-Type": "application/json" },
1936
+ body: JSON.stringify({}),
1937
+ });
1938
+
1939
+ expect(res.status).toBe(400);
1940
+ const json = await res.json();
1941
+ expect(json).toEqual({ error: "At least one settings field is required" });
1942
+ });
1943
+
1944
+ // Validates that claudeCodeOAuthToken must be a string
1945
+ it("returns 400 for non-string claudeCodeOAuthToken", async () => {
1946
+ const res = await app.request("/api/settings", {
1947
+ method: "PUT",
1948
+ headers: { "Content-Type": "application/json" },
1949
+ body: JSON.stringify({ claudeCodeOAuthToken: 123 }),
1950
+ });
1951
+ expect(res.status).toBe(400);
1952
+ const json = await res.json();
1953
+ expect(json).toEqual({ error: "claudeCodeOAuthToken must be a string" });
1954
+ });
1955
+
1956
+ // Validates that openaiApiKey must be a string
1957
+ it("returns 400 for non-string openaiApiKey", async () => {
1958
+ const res = await app.request("/api/settings", {
1959
+ method: "PUT",
1960
+ headers: { "Content-Type": "application/json" },
1961
+ body: JSON.stringify({ openaiApiKey: true }),
1962
+ });
1963
+ expect(res.status).toBe(400);
1964
+ const json = await res.json();
1965
+ expect(json).toEqual({ error: "openaiApiKey must be a string" });
1966
+ });
1967
+
1968
+ // Validates that onboardingCompleted must be a boolean
1969
+ it("returns 400 for non-boolean onboardingCompleted", async () => {
1970
+ const res = await app.request("/api/settings", {
1971
+ method: "PUT",
1972
+ headers: { "Content-Type": "application/json" },
1973
+ body: JSON.stringify({ onboardingCompleted: "yes" }),
1974
+ });
1975
+ expect(res.status).toBe(400);
1976
+ const json = await res.json();
1977
+ expect(json).toEqual({ error: "onboardingCompleted must be a boolean" });
1978
+ });
1979
+
1980
+ // Validates that dockerAutoUpdate must be a boolean
1981
+ it("returns 400 for non-boolean dockerAutoUpdate", async () => {
1982
+ const res = await app.request("/api/settings", {
1983
+ method: "PUT",
1984
+ headers: { "Content-Type": "application/json" },
1985
+ body: JSON.stringify({ dockerAutoUpdate: "yes" }),
1986
+ });
1987
+ expect(res.status).toBe(400);
1988
+ const json = await res.json();
1989
+ expect(json).toEqual({ error: "dockerAutoUpdate must be a boolean" });
1990
+ });
1991
+ });
1992
+
1993
+ describe("POST /api/settings/anthropic/verify", () => {
1994
+ it("returns 400 when no apiKey provided", async () => {
1995
+ // Verifies the endpoint rejects requests that omit the apiKey field
1996
+ const res = await app.request("/api/settings/anthropic/verify", {
1997
+ method: "POST",
1998
+ headers: { "Content-Type": "application/json" },
1999
+ body: JSON.stringify({}),
2000
+ });
2001
+
2002
+ expect(res.status).toBe(400);
2003
+ const json = await res.json();
2004
+ expect(json).toEqual({ valid: false, error: "API key is required" });
2005
+ });
2006
+
2007
+ it("returns valid:true when fetch succeeds", async () => {
2008
+ // Verifies successful Anthropic API key validation when the upstream API responds ok
2009
+ const fetchMock = vi.fn().mockResolvedValue({
2010
+ ok: true,
2011
+ status: 200,
2012
+ });
2013
+ vi.stubGlobal("fetch", fetchMock);
2014
+
2015
+ const res = await app.request("/api/settings/anthropic/verify", {
2016
+ method: "POST",
2017
+ headers: { "Content-Type": "application/json" },
2018
+ body: JSON.stringify({ apiKey: "sk-ant-valid-key" }),
2019
+ });
2020
+
2021
+ expect(res.status).toBe(200);
2022
+ const json = await res.json();
2023
+ expect(json).toEqual({ valid: true });
2024
+
2025
+ // Verify the correct Anthropic API endpoint and headers were used
2026
+ expect(fetchMock).toHaveBeenCalledWith(
2027
+ "https://api.anthropic.com/v1/models",
2028
+ expect.objectContaining({
2029
+ headers: expect.objectContaining({
2030
+ "x-api-key": "sk-ant-valid-key",
2031
+ "anthropic-version": "2023-06-01",
2032
+ }),
2033
+ }),
2034
+ );
2035
+
2036
+ vi.unstubAllGlobals();
2037
+ });
2038
+
2039
+ it("returns valid:false with error when fetch returns non-ok", async () => {
2040
+ // Verifies the endpoint correctly reports invalid keys when the Anthropic API rejects them
2041
+ const fetchMock = vi.fn().mockResolvedValue({
2042
+ ok: false,
2043
+ status: 401,
2044
+ });
2045
+ vi.stubGlobal("fetch", fetchMock);
2046
+
2047
+ const res = await app.request("/api/settings/anthropic/verify", {
2048
+ method: "POST",
2049
+ headers: { "Content-Type": "application/json" },
2050
+ body: JSON.stringify({ apiKey: "sk-ant-invalid-key" }),
2051
+ });
2052
+
2053
+ expect(res.status).toBe(200);
2054
+ const json = await res.json();
2055
+ expect(json).toEqual({ valid: false, error: "API returned 401" });
2056
+
2057
+ vi.unstubAllGlobals();
2058
+ });
2059
+
2060
+ it("returns valid:false when fetch throws", async () => {
2061
+ // Verifies graceful error handling when the network request itself fails
2062
+ const fetchMock = vi.fn().mockRejectedValue(new Error("Network error"));
2063
+ vi.stubGlobal("fetch", fetchMock);
2064
+
2065
+ const res = await app.request("/api/settings/anthropic/verify", {
2066
+ method: "POST",
2067
+ headers: { "Content-Type": "application/json" },
2068
+ body: JSON.stringify({ apiKey: "sk-ant-some-key" }),
2069
+ });
2070
+
2071
+ expect(res.status).toBe(200);
2072
+ const json = await res.json();
2073
+ expect(json).toEqual({ valid: false, error: "Request failed" });
2074
+
2075
+ vi.unstubAllGlobals();
2076
+ });
2077
+ });
2078
+
2079
+ describe("GET /api/linear/issues", () => {
2080
+ it("returns empty list when query is blank", async () => {
2081
+ const res = await app.request("/api/linear/issues?query= ", { method: "GET" });
2082
+ expect(res.status).toBe(200);
2083
+ const json = await res.json();
2084
+ expect(json).toEqual({ issues: [] });
2085
+ });
2086
+
2087
+ it("returns 400 when linear key is not configured", async () => {
2088
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
2089
+ anthropicApiKey: "",
2090
+ anthropicModel: "claude-sonnet-4-6",
2091
+ linearApiKey: "",
2092
+ linearAutoTransition: false,
2093
+ linearAutoTransitionStateId: "",
2094
+ linearAutoTransitionStateName: "",
2095
+ linearArchiveTransition: false,
2096
+ linearArchiveTransitionStateId: "",
2097
+ linearArchiveTransitionStateName: "",
2098
+ linearOAuthClientId: "",
2099
+ linearOAuthClientSecret: "",
2100
+ linearOAuthWebhookSecret: "",
2101
+ linearOAuthAccessToken: "",
2102
+ linearOAuthRefreshToken: "",
2103
+ claudeCodeOAuthToken: "",
2104
+ openaiApiKey: "",
2105
+ onboardingCompleted: false,
2106
+ aiValidationEnabled: false,
2107
+ aiValidationAutoApprove: true,
2108
+ aiValidationAutoDeny: false,
2109
+ publicUrl: "",
2110
+ updateChannel: "stable",
2111
+ dockerAutoUpdate: false,
2112
+ updatedAt: 0,
2113
+ });
2114
+ vi.mocked(resolveApiKey).mockReturnValue(null);
2115
+
2116
+ const res = await app.request("/api/linear/issues?query=auth", { method: "GET" });
2117
+ expect(res.status).toBe(400);
2118
+ const json = await res.json();
2119
+ expect(json).toEqual({ error: "No Linear connection configured" });
2120
+ });
2121
+
2122
+ it("proxies Linear issue search results with branchName", async () => {
2123
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
2124
+ anthropicApiKey: "",
2125
+ anthropicModel: "claude-sonnet-4-6",
2126
+ linearApiKey: "lin_api_123",
2127
+ linearAutoTransition: false,
2128
+ linearAutoTransitionStateId: "",
2129
+ linearAutoTransitionStateName: "",
2130
+ linearArchiveTransition: false,
2131
+ linearArchiveTransitionStateId: "",
2132
+ linearArchiveTransitionStateName: "",
2133
+ linearOAuthClientId: "",
2134
+ linearOAuthClientSecret: "",
2135
+ linearOAuthWebhookSecret: "",
2136
+ linearOAuthAccessToken: "",
2137
+ linearOAuthRefreshToken: "",
2138
+ claudeCodeOAuthToken: "",
2139
+ openaiApiKey: "",
2140
+ onboardingCompleted: false,
2141
+ aiValidationEnabled: false,
2142
+ aiValidationAutoApprove: true,
2143
+ aiValidationAutoDeny: false,
2144
+ publicUrl: "",
2145
+ updateChannel: "stable",
2146
+ dockerAutoUpdate: false,
2147
+ updatedAt: 0,
2148
+ });
2149
+
2150
+ const fetchMock = vi.fn().mockResolvedValue({
2151
+ ok: true,
2152
+ statusText: "OK",
2153
+ json: async () => ({
2154
+ data: {
2155
+ searchIssues: {
2156
+ nodes: [{
2157
+ id: "issue-id",
2158
+ identifier: "ENG-123",
2159
+ title: "Fix auth flow",
2160
+ description: "401 on refresh token",
2161
+ url: "https://linear.app/acme/issue/ENG-123/fix-auth-flow",
2162
+ branchName: "eng-123-fix-auth-flow",
2163
+ priorityLabel: "High",
2164
+ state: { name: "In Progress", type: "started" },
2165
+ team: { id: "team-eng-1", key: "ENG", name: "Engineering" },
2166
+ }],
2167
+ },
2168
+ },
2169
+ }),
2170
+ });
2171
+ vi.stubGlobal("fetch", fetchMock);
2172
+
2173
+ const res = await app.request("/api/linear/issues?query=auth&limit=5", { method: "GET" });
2174
+ expect(res.status).toBe(200);
2175
+ const json = await res.json();
2176
+ expect(json).toEqual({
2177
+ issues: [{
2178
+ id: "issue-id",
2179
+ identifier: "ENG-123",
2180
+ title: "Fix auth flow",
2181
+ description: "401 on refresh token",
2182
+ url: "https://linear.app/acme/issue/ENG-123/fix-auth-flow",
2183
+ branchName: "eng-123-fix-auth-flow",
2184
+ priorityLabel: "High",
2185
+ stateName: "In Progress",
2186
+ stateType: "started",
2187
+ teamName: "Engineering",
2188
+ teamKey: "ENG",
2189
+ teamId: "team-eng-1",
2190
+ }],
2191
+ });
2192
+ expect(fetchMock).toHaveBeenCalledWith(
2193
+ "https://api.linear.app/graphql",
2194
+ expect.objectContaining({
2195
+ method: "POST",
2196
+ headers: expect.objectContaining({ Authorization: "lin_api_123" }),
2197
+ }),
2198
+ );
2199
+ const [, requestInit] = vi.mocked(fetchMock).mock.calls[0];
2200
+ const requestBody = JSON.parse(String(requestInit?.body ?? "{}"));
2201
+ // Verify branchName is requested in the GraphQL query
2202
+ expect(requestBody.query).toContain("branchName");
2203
+ expect(requestBody.query).toContain("searchIssues(term: $term, first: $first)");
2204
+ expect(requestBody.variables).toEqual({ term: "auth", first: 5 });
2205
+ vi.unstubAllGlobals();
2206
+ });
2207
+
2208
+ it("returns only active issues and orders backlog-like before in-progress", async () => {
2209
+ // The home page issue picker should hide done/cancelled work and show backlog-like
2210
+ // items before currently started ones.
2211
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
2212
+ anthropicApiKey: "",
2213
+ anthropicModel: "claude-sonnet-4-6",
2214
+ linearApiKey: "lin_api_123",
2215
+ linearAutoTransition: false,
2216
+ linearAutoTransitionStateId: "",
2217
+ linearAutoTransitionStateName: "",
2218
+ linearArchiveTransition: false,
2219
+ linearArchiveTransitionStateId: "",
2220
+ linearArchiveTransitionStateName: "",
2221
+ linearOAuthClientId: "",
2222
+ linearOAuthClientSecret: "",
2223
+ linearOAuthWebhookSecret: "",
2224
+ linearOAuthAccessToken: "",
2225
+ linearOAuthRefreshToken: "",
2226
+ claudeCodeOAuthToken: "",
2227
+ openaiApiKey: "",
2228
+ onboardingCompleted: false,
2229
+ aiValidationEnabled: false,
2230
+ aiValidationAutoApprove: true,
2231
+ aiValidationAutoDeny: false,
2232
+ publicUrl: "",
2233
+ updateChannel: "stable",
2234
+ dockerAutoUpdate: false,
2235
+ updatedAt: 0,
2236
+ });
2237
+
2238
+ const fetchMock = vi.fn().mockResolvedValue({
2239
+ ok: true,
2240
+ statusText: "OK",
2241
+ json: async () => ({
2242
+ data: {
2243
+ searchIssues: {
2244
+ nodes: [
2245
+ {
2246
+ id: "done-1",
2247
+ identifier: "ENG-10",
2248
+ title: "Already done",
2249
+ description: "",
2250
+ url: "https://linear.app/acme/issue/ENG-10",
2251
+ branchName: null,
2252
+ priorityLabel: null,
2253
+ state: { name: "Done", type: "completed" },
2254
+ team: { id: "team-1", key: "ENG", name: "Engineering" },
2255
+ },
2256
+ {
2257
+ id: "started-1",
2258
+ identifier: "ENG-11",
2259
+ title: "Implement feature",
2260
+ description: "",
2261
+ url: "https://linear.app/acme/issue/ENG-11",
2262
+ branchName: null,
2263
+ priorityLabel: null,
2264
+ state: { name: "In Progress", type: "started" },
2265
+ team: { id: "team-1", key: "ENG", name: "Engineering" },
2266
+ },
2267
+ {
2268
+ id: "backlog-1",
2269
+ identifier: "ENG-12",
2270
+ title: "Investigate bug",
2271
+ description: "",
2272
+ url: "https://linear.app/acme/issue/ENG-12",
2273
+ branchName: null,
2274
+ priorityLabel: null,
2275
+ state: { name: "Backlog", type: "unstarted" },
2276
+ team: { id: "team-1", key: "ENG", name: "Engineering" },
2277
+ },
2278
+ {
2279
+ id: "cancelled-1",
2280
+ identifier: "ENG-13",
2281
+ title: "Won't do",
2282
+ description: "",
2283
+ url: "https://linear.app/acme/issue/ENG-13",
2284
+ branchName: null,
2285
+ priorityLabel: null,
2286
+ state: { name: "Cancelled", type: "cancelled" },
2287
+ team: { id: "team-1", key: "ENG", name: "Engineering" },
2288
+ },
2289
+ ],
2290
+ },
2291
+ },
2292
+ }),
2293
+ });
2294
+ vi.stubGlobal("fetch", fetchMock);
2295
+
2296
+ const res = await app.request("/api/linear/issues?query=eng", { method: "GET" });
2297
+ expect(res.status).toBe(200);
2298
+ const json = await res.json();
2299
+ expect(json.issues.map((i: { identifier: string }) => i.identifier)).toEqual(["ENG-12", "ENG-11"]);
2300
+ vi.unstubAllGlobals();
2301
+ });
2302
+
2303
+ it("returns empty branchName when Linear does not provide one", async () => {
2304
+ // Verifies fallback: when branchName is null/missing from Linear API,
2305
+ // the response maps it to an empty string so the frontend can generate a slug
2306
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
2307
+ anthropicApiKey: "",
2308
+ anthropicModel: "claude-sonnet-4-6",
2309
+ linearApiKey: "lin_api_123",
2310
+ linearAutoTransition: false,
2311
+ linearAutoTransitionStateId: "",
2312
+ linearAutoTransitionStateName: "",
2313
+ linearArchiveTransition: false,
2314
+ linearArchiveTransitionStateId: "",
2315
+ linearArchiveTransitionStateName: "",
2316
+ linearOAuthClientId: "",
2317
+ linearOAuthClientSecret: "",
2318
+ linearOAuthWebhookSecret: "",
2319
+ linearOAuthAccessToken: "",
2320
+ linearOAuthRefreshToken: "",
2321
+ claudeCodeOAuthToken: "",
2322
+ openaiApiKey: "",
2323
+ onboardingCompleted: false,
2324
+ aiValidationEnabled: false,
2325
+ aiValidationAutoApprove: true,
2326
+ aiValidationAutoDeny: false,
2327
+ publicUrl: "",
2328
+ updateChannel: "stable",
2329
+ dockerAutoUpdate: false,
2330
+ updatedAt: 0,
2331
+ });
2332
+
2333
+ const fetchMock = vi.fn().mockResolvedValue({
2334
+ ok: true,
2335
+ statusText: "OK",
2336
+ json: async () => ({
2337
+ data: {
2338
+ searchIssues: {
2339
+ nodes: [{
2340
+ id: "issue-id-2",
2341
+ identifier: "ENG-456",
2342
+ title: "Add dark mode",
2343
+ description: null,
2344
+ url: "https://linear.app/acme/issue/ENG-456/add-dark-mode",
2345
+ branchName: null,
2346
+ priorityLabel: null,
2347
+ state: null,
2348
+ team: null,
2349
+ }],
2350
+ },
2351
+ },
2352
+ }),
2353
+ });
2354
+ vi.stubGlobal("fetch", fetchMock);
2355
+
2356
+ const res = await app.request("/api/linear/issues?query=dark", { method: "GET" });
2357
+ expect(res.status).toBe(200);
2358
+ const json = await res.json();
2359
+ expect(json.issues[0].branchName).toBe("");
2360
+ vi.unstubAllGlobals();
2361
+ });
2362
+ });
2363
+
2364
+ describe("GET /api/linear/connection", () => {
2365
+ it("returns 400 when linear key is not configured", async () => {
2366
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
2367
+ anthropicApiKey: "",
2368
+ anthropicModel: "claude-sonnet-4-6",
2369
+ linearApiKey: "",
2370
+ linearAutoTransition: false,
2371
+ linearAutoTransitionStateId: "",
2372
+ linearAutoTransitionStateName: "",
2373
+ linearArchiveTransition: false,
2374
+ linearArchiveTransitionStateId: "",
2375
+ linearArchiveTransitionStateName: "",
2376
+ linearOAuthClientId: "",
2377
+ linearOAuthClientSecret: "",
2378
+ linearOAuthWebhookSecret: "",
2379
+ linearOAuthAccessToken: "",
2380
+ linearOAuthRefreshToken: "",
2381
+ claudeCodeOAuthToken: "",
2382
+ openaiApiKey: "",
2383
+ onboardingCompleted: false,
2384
+ aiValidationEnabled: false,
2385
+ aiValidationAutoApprove: true,
2386
+ aiValidationAutoDeny: false,
2387
+ publicUrl: "",
2388
+ updateChannel: "stable",
2389
+ dockerAutoUpdate: false,
2390
+ updatedAt: 0,
2391
+ });
2392
+ vi.mocked(resolveApiKey).mockReturnValue(null);
2393
+
2394
+ const res = await app.request("/api/linear/connection", { method: "GET" });
2395
+ expect(res.status).toBe(400);
2396
+ const json = await res.json();
2397
+ expect(json).toEqual({ error: "No Linear connection configured" });
2398
+ });
2399
+
2400
+ it("returns viewer/team info when connection works", async () => {
2401
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
2402
+ anthropicApiKey: "",
2403
+ anthropicModel: "claude-sonnet-4-6",
2404
+ linearApiKey: "lin_api_123",
2405
+ linearAutoTransition: false,
2406
+ linearAutoTransitionStateId: "",
2407
+ linearAutoTransitionStateName: "",
2408
+ linearArchiveTransition: false,
2409
+ linearArchiveTransitionStateId: "",
2410
+ linearArchiveTransitionStateName: "",
2411
+ linearOAuthClientId: "",
2412
+ linearOAuthClientSecret: "",
2413
+ linearOAuthWebhookSecret: "",
2414
+ linearOAuthAccessToken: "",
2415
+ linearOAuthRefreshToken: "",
2416
+ claudeCodeOAuthToken: "",
2417
+ openaiApiKey: "",
2418
+ onboardingCompleted: false,
2419
+ aiValidationEnabled: false,
2420
+ aiValidationAutoApprove: true,
2421
+ aiValidationAutoDeny: false,
2422
+ publicUrl: "",
2423
+ updateChannel: "stable",
2424
+ dockerAutoUpdate: false,
2425
+ updatedAt: 0,
2426
+ });
2427
+
2428
+ const fetchMock = vi.fn().mockResolvedValue({
2429
+ ok: true,
2430
+ statusText: "OK",
2431
+ json: async () => ({
2432
+ data: {
2433
+ viewer: { id: "u1", name: "Ada", email: "ada@example.com" },
2434
+ teams: { nodes: [{ id: "t1", key: "ENG", name: "Engineering" }] },
2435
+ },
2436
+ }),
2437
+ });
2438
+ vi.stubGlobal("fetch", fetchMock);
2439
+
2440
+ const res = await app.request("/api/linear/connection", { method: "GET" });
2441
+ expect(res.status).toBe(200);
2442
+ const json = await res.json();
2443
+ expect(json).toEqual({
2444
+ connected: true,
2445
+ viewerId: "u1",
2446
+ viewerName: "Ada",
2447
+ viewerEmail: "ada@example.com",
2448
+ teamName: "Engineering",
2449
+ teamKey: "ENG",
2450
+ });
2451
+ vi.unstubAllGlobals();
2452
+ });
2453
+ });
2454
+
2455
+ describe("POST /api/linear/issues/:id/transition", () => {
2456
+ // Skips when auto-transition is disabled in settings
2457
+ it("skips when auto-transition is disabled", async () => {
2458
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
2459
+ anthropicApiKey: "",
2460
+ anthropicModel: "claude-sonnet-4-6",
2461
+ linearApiKey: "lin_api_123",
2462
+ linearAutoTransition: false,
2463
+ linearAutoTransitionStateId: "state-123",
2464
+ linearAutoTransitionStateName: "In Progress",
2465
+ linearArchiveTransition: false,
2466
+ linearArchiveTransitionStateId: "",
2467
+ linearArchiveTransitionStateName: "",
2468
+ linearOAuthClientId: "",
2469
+ linearOAuthClientSecret: "",
2470
+ linearOAuthWebhookSecret: "",
2471
+ linearOAuthAccessToken: "",
2472
+ linearOAuthRefreshToken: "",
2473
+ claudeCodeOAuthToken: "",
2474
+ openaiApiKey: "",
2475
+ onboardingCompleted: false,
2476
+ aiValidationEnabled: false,
2477
+ aiValidationAutoApprove: true,
2478
+ aiValidationAutoDeny: false,
2479
+ publicUrl: "",
2480
+ updateChannel: "stable",
2481
+ dockerAutoUpdate: false,
2482
+ updatedAt: 0,
2483
+ });
2484
+
2485
+ const res = await app.request("/api/linear/issues/issue-123/transition", {
2486
+ method: "POST",
2487
+ headers: { "Content-Type": "application/json" },
2488
+ body: JSON.stringify({}),
2489
+ });
2490
+ expect(res.status).toBe(200);
2491
+ const json = await res.json();
2492
+ expect(json).toEqual({ ok: true, skipped: true, reason: "auto_transition_disabled" });
2493
+ });
2494
+
2495
+ // Skips when no target state is configured
2496
+ it("skips when no target state is configured", async () => {
2497
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
2498
+ anthropicApiKey: "",
2499
+ anthropicModel: "claude-sonnet-4-6",
2500
+ linearApiKey: "lin_api_123",
2501
+ linearAutoTransition: true,
2502
+ linearAutoTransitionStateId: "",
2503
+ linearAutoTransitionStateName: "",
2504
+ linearArchiveTransition: false,
2505
+ linearArchiveTransitionStateId: "",
2506
+ linearArchiveTransitionStateName: "",
2507
+ linearOAuthClientId: "",
2508
+ linearOAuthClientSecret: "",
2509
+ linearOAuthWebhookSecret: "",
2510
+ linearOAuthAccessToken: "",
2511
+ linearOAuthRefreshToken: "",
2512
+ claudeCodeOAuthToken: "",
2513
+ openaiApiKey: "",
2514
+ onboardingCompleted: false,
2515
+ aiValidationEnabled: false,
2516
+ aiValidationAutoApprove: true,
2517
+ aiValidationAutoDeny: false,
2518
+ publicUrl: "",
2519
+ updateChannel: "stable",
2520
+ dockerAutoUpdate: false,
2521
+ updatedAt: 0,
2522
+ });
2523
+
2524
+ const res = await app.request("/api/linear/issues/issue-123/transition", {
2525
+ method: "POST",
2526
+ headers: { "Content-Type": "application/json" },
2527
+ body: JSON.stringify({}),
2528
+ });
2529
+ expect(res.status).toBe(200);
2530
+ const json = await res.json();
2531
+ expect(json).toEqual({ ok: true, skipped: true, reason: "no_target_state_configured" });
2532
+ });
2533
+
2534
+ it("returns 400 when linear key is not configured", async () => {
2535
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
2536
+ anthropicApiKey: "",
2537
+ anthropicModel: "claude-sonnet-4-6",
2538
+ linearApiKey: "",
2539
+ linearAutoTransition: true,
2540
+ linearAutoTransitionStateId: "state-123",
2541
+ linearAutoTransitionStateName: "In Progress",
2542
+ linearArchiveTransition: false,
2543
+ linearArchiveTransitionStateId: "",
2544
+ linearArchiveTransitionStateName: "",
2545
+ linearOAuthClientId: "",
2546
+ linearOAuthClientSecret: "",
2547
+ linearOAuthWebhookSecret: "",
2548
+ linearOAuthAccessToken: "",
2549
+ linearOAuthRefreshToken: "",
2550
+ claudeCodeOAuthToken: "",
2551
+ openaiApiKey: "",
2552
+ onboardingCompleted: false,
2553
+ aiValidationEnabled: false,
2554
+ aiValidationAutoApprove: true,
2555
+ aiValidationAutoDeny: false,
2556
+ publicUrl: "",
2557
+ updateChannel: "stable",
2558
+ dockerAutoUpdate: false,
2559
+ updatedAt: 0,
2560
+ });
2561
+ vi.mocked(resolveApiKey).mockReturnValue(null);
2562
+
2563
+ const res = await app.request("/api/linear/issues/issue-123/transition", {
2564
+ method: "POST",
2565
+ headers: { "Content-Type": "application/json" },
2566
+ body: JSON.stringify({}),
2567
+ });
2568
+ expect(res.status).toBe(400);
2569
+ const json = await res.json();
2570
+ expect(json).toEqual({ error: "No Linear connection configured" });
2571
+ });
2572
+
2573
+ // Happy path: uses configured stateId to update the issue directly
2574
+ it("transitions issue to configured state", async () => {
2575
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
2576
+ anthropicApiKey: "",
2577
+ anthropicModel: "claude-sonnet-4-6",
2578
+ linearApiKey: "lin_api_123",
2579
+ linearAutoTransition: true,
2580
+ linearAutoTransitionStateId: "state-doing",
2581
+ linearAutoTransitionStateName: "Doing",
2582
+ linearArchiveTransition: false,
2583
+ linearArchiveTransitionStateId: "",
2584
+ linearArchiveTransitionStateName: "",
2585
+ linearOAuthClientId: "",
2586
+ linearOAuthClientSecret: "",
2587
+ linearOAuthWebhookSecret: "",
2588
+ linearOAuthAccessToken: "",
2589
+ linearOAuthRefreshToken: "",
2590
+ claudeCodeOAuthToken: "",
2591
+ openaiApiKey: "",
2592
+ onboardingCompleted: false,
2593
+ aiValidationEnabled: false,
2594
+ aiValidationAutoApprove: true,
2595
+ aiValidationAutoDeny: false,
2596
+ publicUrl: "",
2597
+ updateChannel: "stable",
2598
+ dockerAutoUpdate: false,
2599
+ updatedAt: 0,
2600
+ });
2601
+
2602
+ const fetchMock = vi.fn().mockResolvedValueOnce({
2603
+ ok: true,
2604
+ statusText: "OK",
2605
+ json: async () => ({
2606
+ data: {
2607
+ issueUpdate: {
2608
+ success: true,
2609
+ issue: {
2610
+ id: "issue-123",
2611
+ identifier: "ENG-456",
2612
+ state: { name: "Doing", type: "started" },
2613
+ },
2614
+ },
2615
+ },
2616
+ }),
2617
+ });
2618
+ vi.stubGlobal("fetch", fetchMock);
2619
+
2620
+ const res = await app.request("/api/linear/issues/issue-123/transition", {
2621
+ method: "POST",
2622
+ headers: { "Content-Type": "application/json" },
2623
+ body: JSON.stringify({}),
2624
+ });
2625
+ expect(res.status).toBe(200);
2626
+ const json = await res.json();
2627
+ expect(json).toEqual({
2628
+ ok: true,
2629
+ skipped: false,
2630
+ issue: {
2631
+ id: "issue-123",
2632
+ identifier: "ENG-456",
2633
+ stateName: "Doing",
2634
+ stateType: "started",
2635
+ },
2636
+ });
2637
+
2638
+ // Verify only one GraphQL call (no states query needed)
2639
+ expect(fetchMock).toHaveBeenCalledTimes(1);
2640
+ const body = JSON.parse(String(fetchMock.mock.calls[0][1]?.body ?? "{}"));
2641
+ expect(body.query).toContain("issueUpdate");
2642
+ expect(body.variables).toEqual({ issueId: "issue-123", stateId: "state-doing" });
2643
+
2644
+ vi.unstubAllGlobals();
2645
+ });
2646
+
2647
+ // Error case: Linear API returns an error when updating issue state
2648
+ it("returns 502 when issue update fails", async () => {
2649
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
2650
+ anthropicApiKey: "",
2651
+ anthropicModel: "claude-sonnet-4-6",
2652
+ linearApiKey: "lin_api_123",
2653
+ linearAutoTransition: true,
2654
+ linearAutoTransitionStateId: "state-doing",
2655
+ linearAutoTransitionStateName: "Doing",
2656
+ linearArchiveTransition: false,
2657
+ linearArchiveTransitionStateId: "",
2658
+ linearArchiveTransitionStateName: "",
2659
+ linearOAuthClientId: "",
2660
+ linearOAuthClientSecret: "",
2661
+ linearOAuthWebhookSecret: "",
2662
+ linearOAuthAccessToken: "",
2663
+ linearOAuthRefreshToken: "",
2664
+ claudeCodeOAuthToken: "",
2665
+ openaiApiKey: "",
2666
+ onboardingCompleted: false,
2667
+ aiValidationEnabled: false,
2668
+ aiValidationAutoApprove: true,
2669
+ aiValidationAutoDeny: false,
2670
+ publicUrl: "",
2671
+ updateChannel: "stable",
2672
+ dockerAutoUpdate: false,
2673
+ updatedAt: 0,
2674
+ });
2675
+
2676
+ const fetchMock = vi.fn().mockResolvedValueOnce({
2677
+ ok: false,
2678
+ statusText: "Bad Request",
2679
+ json: async () => ({
2680
+ errors: [{ message: "Issue not found" }],
2681
+ }),
2682
+ });
2683
+ vi.stubGlobal("fetch", fetchMock);
2684
+
2685
+ const res = await app.request("/api/linear/issues/issue-123/transition", {
2686
+ method: "POST",
2687
+ headers: { "Content-Type": "application/json" },
2688
+ body: JSON.stringify({}),
2689
+ });
2690
+ expect(res.status).toBe(502);
2691
+ const json = await res.json();
2692
+ expect(json).toEqual({ error: "Issue not found" });
2693
+
2694
+ vi.unstubAllGlobals();
2695
+ });
2696
+ });
2697
+
2698
+ // ─── Linear projects ─────────────────────────────────────────────────────────
2699
+
2700
+ describe("GET /api/linear/projects", () => {
2701
+ it("returns 400 when linear key is not configured", async () => {
2702
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
2703
+ anthropicApiKey: "",
2704
+ anthropicModel: "claude-sonnet-4-6",
2705
+ linearApiKey: "",
2706
+ linearAutoTransition: false,
2707
+ linearAutoTransitionStateId: "",
2708
+ linearAutoTransitionStateName: "",
2709
+ linearArchiveTransition: false,
2710
+ linearArchiveTransitionStateId: "",
2711
+ linearArchiveTransitionStateName: "",
2712
+ linearOAuthClientId: "",
2713
+ linearOAuthClientSecret: "",
2714
+ linearOAuthWebhookSecret: "",
2715
+ linearOAuthAccessToken: "",
2716
+ linearOAuthRefreshToken: "",
2717
+ claudeCodeOAuthToken: "",
2718
+ openaiApiKey: "",
2719
+ onboardingCompleted: false,
2720
+ aiValidationEnabled: false,
2721
+ aiValidationAutoApprove: true,
2722
+ aiValidationAutoDeny: false,
2723
+ publicUrl: "",
2724
+ updateChannel: "stable",
2725
+ dockerAutoUpdate: false,
2726
+ updatedAt: 0,
2727
+ });
2728
+ vi.mocked(resolveApiKey).mockReturnValue(null);
2729
+
2730
+ const res = await app.request("/api/linear/projects", { method: "GET" });
2731
+ expect(res.status).toBe(400);
2732
+ const json = await res.json();
2733
+ expect(json).toEqual({ error: "No Linear connection configured" });
2734
+ });
2735
+
2736
+ it("returns project list from Linear API", async () => {
2737
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
2738
+ anthropicApiKey: "",
2739
+ anthropicModel: "claude-sonnet-4-6",
2740
+ linearApiKey: "lin_api_123",
2741
+ linearAutoTransition: false,
2742
+ linearAutoTransitionStateId: "",
2743
+ linearAutoTransitionStateName: "",
2744
+ linearArchiveTransition: false,
2745
+ linearArchiveTransitionStateId: "",
2746
+ linearArchiveTransitionStateName: "",
2747
+ linearOAuthClientId: "",
2748
+ linearOAuthClientSecret: "",
2749
+ linearOAuthWebhookSecret: "",
2750
+ linearOAuthAccessToken: "",
2751
+ linearOAuthRefreshToken: "",
2752
+ claudeCodeOAuthToken: "",
2753
+ openaiApiKey: "",
2754
+ onboardingCompleted: false,
2755
+ aiValidationEnabled: false,
2756
+ aiValidationAutoApprove: true,
2757
+ aiValidationAutoDeny: false,
2758
+ publicUrl: "",
2759
+ updateChannel: "stable",
2760
+ dockerAutoUpdate: false,
2761
+ updatedAt: 0,
2762
+ });
2763
+
2764
+ const fetchMock = vi.fn().mockResolvedValue({
2765
+ ok: true,
2766
+ statusText: "OK",
2767
+ json: async () => ({
2768
+ data: {
2769
+ projects: {
2770
+ nodes: [
2771
+ { id: "p1", name: "My Feature", state: "started" },
2772
+ { id: "p2", name: "Backend Rework", state: "planned" },
2773
+ ],
2774
+ },
2775
+ },
2776
+ }),
2777
+ });
2778
+ vi.stubGlobal("fetch", fetchMock);
2779
+
2780
+ const res = await app.request("/api/linear/projects", { method: "GET" });
2781
+ expect(res.status).toBe(200);
2782
+ const json = await res.json();
2783
+ expect(json).toEqual({
2784
+ projects: [
2785
+ { id: "p1", name: "My Feature", state: "started" },
2786
+ { id: "p2", name: "Backend Rework", state: "planned" },
2787
+ ],
2788
+ });
2789
+ vi.unstubAllGlobals();
2790
+ });
2791
+ });
2792
+
2793
+ describe("GET /api/linear/project-issues", () => {
2794
+ it("returns 400 when projectId is missing", async () => {
2795
+ const res = await app.request("/api/linear/project-issues", { method: "GET" });
2796
+ expect(res.status).toBe(400);
2797
+ const json = await res.json();
2798
+ expect(json).toEqual({ error: "projectId is required" });
2799
+ });
2800
+
2801
+ it("returns 400 when linear key is not configured", async () => {
2802
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
2803
+ anthropicApiKey: "",
2804
+ anthropicModel: "claude-sonnet-4-6",
2805
+ linearApiKey: "",
2806
+ linearAutoTransition: false,
2807
+ linearAutoTransitionStateId: "",
2808
+ linearAutoTransitionStateName: "",
2809
+ linearArchiveTransition: false,
2810
+ linearArchiveTransitionStateId: "",
2811
+ linearArchiveTransitionStateName: "",
2812
+ linearOAuthClientId: "",
2813
+ linearOAuthClientSecret: "",
2814
+ linearOAuthWebhookSecret: "",
2815
+ linearOAuthAccessToken: "",
2816
+ linearOAuthRefreshToken: "",
2817
+ claudeCodeOAuthToken: "",
2818
+ openaiApiKey: "",
2819
+ onboardingCompleted: false,
2820
+ aiValidationEnabled: false,
2821
+ aiValidationAutoApprove: true,
2822
+ aiValidationAutoDeny: false,
2823
+ publicUrl: "",
2824
+ updateChannel: "stable",
2825
+ dockerAutoUpdate: false,
2826
+ updatedAt: 0,
2827
+ });
2828
+ vi.mocked(resolveApiKey).mockReturnValue(null);
2829
+
2830
+ const res = await app.request("/api/linear/project-issues?projectId=p1", { method: "GET" });
2831
+ expect(res.status).toBe(400);
2832
+ const json = await res.json();
2833
+ expect(json).toEqual({ error: "No Linear connection configured" });
2834
+ });
2835
+
2836
+ it("returns recent non-done issues for a project", async () => {
2837
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
2838
+ anthropicApiKey: "",
2839
+ anthropicModel: "claude-sonnet-4-6",
2840
+ linearApiKey: "lin_api_123",
2841
+ linearAutoTransition: false,
2842
+ linearAutoTransitionStateId: "",
2843
+ linearAutoTransitionStateName: "",
2844
+ linearArchiveTransition: false,
2845
+ linearArchiveTransitionStateId: "",
2846
+ linearArchiveTransitionStateName: "",
2847
+ linearOAuthClientId: "",
2848
+ linearOAuthClientSecret: "",
2849
+ linearOAuthWebhookSecret: "",
2850
+ linearOAuthAccessToken: "",
2851
+ linearOAuthRefreshToken: "",
2852
+ claudeCodeOAuthToken: "",
2853
+ openaiApiKey: "",
2854
+ onboardingCompleted: false,
2855
+ aiValidationEnabled: false,
2856
+ aiValidationAutoApprove: true,
2857
+ aiValidationAutoDeny: false,
2858
+ publicUrl: "",
2859
+ updateChannel: "stable",
2860
+ dockerAutoUpdate: false,
2861
+ updatedAt: 0,
2862
+ });
2863
+
2864
+ const fetchMock = vi.fn().mockResolvedValue({
2865
+ ok: true,
2866
+ statusText: "OK",
2867
+ json: async () => ({
2868
+ data: {
2869
+ issues: {
2870
+ nodes: [{
2871
+ id: "issue-1",
2872
+ identifier: "ENG-42",
2873
+ title: "Implement dark mode",
2874
+ description: "Add theme support",
2875
+ url: "https://linear.app/acme/issue/ENG-42",
2876
+ priorityLabel: "Medium",
2877
+ state: { name: "In Progress", type: "started" },
2878
+ team: { key: "ENG", name: "Engineering" },
2879
+ assignee: { name: "Ada" },
2880
+ updatedAt: "2026-02-19T10:00:00Z",
2881
+ }],
2882
+ },
2883
+ },
2884
+ }),
2885
+ });
2886
+ vi.stubGlobal("fetch", fetchMock);
2887
+
2888
+ const res = await app.request("/api/linear/project-issues?projectId=p1&limit=5", { method: "GET" });
2889
+ expect(res.status).toBe(200);
2890
+ const json = await res.json();
2891
+ expect(json).toEqual({
2892
+ issues: [{
2893
+ id: "issue-1",
2894
+ identifier: "ENG-42",
2895
+ title: "Implement dark mode",
2896
+ description: "Add theme support",
2897
+ url: "https://linear.app/acme/issue/ENG-42",
2898
+ priorityLabel: "Medium",
2899
+ stateName: "In Progress",
2900
+ stateType: "started",
2901
+ teamName: "Engineering",
2902
+ teamKey: "ENG",
2903
+ assigneeName: "Ada",
2904
+ updatedAt: "2026-02-19T10:00:00Z",
2905
+ }],
2906
+ });
2907
+
2908
+ // Verify the GraphQL query uses projectId variable and correct limit
2909
+ const [, requestInit] = vi.mocked(fetchMock).mock.calls[0];
2910
+ const requestBody = JSON.parse(String(requestInit?.body ?? "{}"));
2911
+ expect(requestBody.variables).toEqual({ projectId: "p1", first: 5 });
2912
+ vi.unstubAllGlobals();
2913
+ });
2914
+
2915
+ it("orders project issues backlog-like first, then in-progress", async () => {
2916
+ // UI issue lists should present queued/backlog work first, followed by started work.
2917
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
2918
+ anthropicApiKey: "",
2919
+ anthropicModel: "claude-sonnet-4-6",
2920
+ linearApiKey: "lin_api_123",
2921
+ linearAutoTransition: false,
2922
+ linearAutoTransitionStateId: "",
2923
+ linearAutoTransitionStateName: "",
2924
+ linearArchiveTransition: false,
2925
+ linearArchiveTransitionStateId: "",
2926
+ linearArchiveTransitionStateName: "",
2927
+ linearOAuthClientId: "",
2928
+ linearOAuthClientSecret: "",
2929
+ linearOAuthWebhookSecret: "",
2930
+ linearOAuthAccessToken: "",
2931
+ linearOAuthRefreshToken: "",
2932
+ claudeCodeOAuthToken: "",
2933
+ openaiApiKey: "",
2934
+ onboardingCompleted: false,
2935
+ aiValidationEnabled: false,
2936
+ aiValidationAutoApprove: true,
2937
+ aiValidationAutoDeny: false,
2938
+ publicUrl: "",
2939
+ updateChannel: "stable",
2940
+ dockerAutoUpdate: false,
2941
+ updatedAt: 0,
2942
+ });
2943
+
2944
+ const fetchMock = vi.fn().mockResolvedValue({
2945
+ ok: true,
2946
+ statusText: "OK",
2947
+ json: async () => ({
2948
+ data: {
2949
+ issues: {
2950
+ nodes: [
2951
+ {
2952
+ id: "started-1",
2953
+ identifier: "ENG-100",
2954
+ title: "Ship API",
2955
+ description: "",
2956
+ url: "https://linear.app/acme/issue/ENG-100",
2957
+ priorityLabel: null,
2958
+ state: { name: "In Progress", type: "started" },
2959
+ team: { key: "ENG", name: "Engineering" },
2960
+ assignee: null,
2961
+ updatedAt: "2026-02-19T10:00:00Z",
2962
+ },
2963
+ {
2964
+ id: "backlog-1",
2965
+ identifier: "ENG-101",
2966
+ title: "Scope feature",
2967
+ description: "",
2968
+ url: "https://linear.app/acme/issue/ENG-101",
2969
+ priorityLabel: null,
2970
+ state: { name: "Backlog", type: "unstarted" },
2971
+ team: { key: "ENG", name: "Engineering" },
2972
+ assignee: null,
2973
+ updatedAt: "2026-02-19T09:00:00Z",
2974
+ },
2975
+ ],
2976
+ },
2977
+ },
2978
+ }),
2979
+ });
2980
+ vi.stubGlobal("fetch", fetchMock);
2981
+
2982
+ const res = await app.request("/api/linear/project-issues?projectId=p1", { method: "GET" });
2983
+ expect(res.status).toBe(200);
2984
+ const json = await res.json();
2985
+ expect(json.issues.map((i: { identifier: string }) => i.identifier)).toEqual(["ENG-101", "ENG-100"]);
2986
+ vi.unstubAllGlobals();
2987
+ });
2988
+ });
2989
+
2990
+ // ─── Linear project mappings ─────────────────────────────────────────────────
2991
+
2992
+ describe("GET /api/linear/project-mappings", () => {
2993
+ it("returns mapping for a specific repoRoot", async () => {
2994
+ const mockMapping = {
2995
+ repoRoot: "/home/user/project",
2996
+ projectId: "p1",
2997
+ projectName: "My Feature",
2998
+ createdAt: 1000,
2999
+ updatedAt: 1000,
3000
+ };
3001
+ vi.mocked(linearProjectManager.getMapping).mockReturnValue(mockMapping);
3002
+
3003
+ const res = await app.request(
3004
+ "/api/linear/project-mappings?repoRoot=%2Fhome%2Fuser%2Fproject",
3005
+ { method: "GET" },
3006
+ );
3007
+ expect(res.status).toBe(200);
3008
+ const json = await res.json();
3009
+ expect(json).toEqual({ mapping: mockMapping });
3010
+ expect(linearProjectManager.getMapping).toHaveBeenCalledWith("/home/user/project");
3011
+ });
3012
+
3013
+ it("returns null mapping when repoRoot has no mapping", async () => {
3014
+ vi.mocked(linearProjectManager.getMapping).mockReturnValue(null);
3015
+
3016
+ const res = await app.request(
3017
+ "/api/linear/project-mappings?repoRoot=%2Funknown",
3018
+ { method: "GET" },
3019
+ );
3020
+ expect(res.status).toBe(200);
3021
+ const json = await res.json();
3022
+ expect(json).toEqual({ mapping: null });
3023
+ });
3024
+
3025
+ it("returns all mappings when no repoRoot specified", async () => {
3026
+ const mockMappings = [
3027
+ { repoRoot: "/repo-a", projectId: "p1", projectName: "My Feature", createdAt: 1000, updatedAt: 1000 },
3028
+ { repoRoot: "/repo-b", projectId: "p2", projectName: "Backend Rework", createdAt: 2000, updatedAt: 2000 },
3029
+ ];
3030
+ vi.mocked(linearProjectManager.listMappings).mockReturnValue(mockMappings);
3031
+
3032
+ const res = await app.request("/api/linear/project-mappings", { method: "GET" });
3033
+ expect(res.status).toBe(200);
3034
+ const json = await res.json();
3035
+ expect(json).toEqual({ mappings: mockMappings });
3036
+ });
3037
+ });
3038
+
3039
+ describe("PUT /api/linear/project-mappings", () => {
3040
+ it("returns 400 when required fields are missing", async () => {
3041
+ const res = await app.request("/api/linear/project-mappings", {
3042
+ method: "PUT",
3043
+ headers: { "Content-Type": "application/json" },
3044
+ body: JSON.stringify({ repoRoot: "/repo" }),
3045
+ });
3046
+ expect(res.status).toBe(400);
3047
+ const json = await res.json();
3048
+ expect(json).toEqual({ error: "repoRoot, projectId, and projectName are required" });
3049
+ });
3050
+
3051
+ it("creates a mapping successfully", async () => {
3052
+ const res = await app.request("/api/linear/project-mappings", {
3053
+ method: "PUT",
3054
+ headers: { "Content-Type": "application/json" },
3055
+ body: JSON.stringify({
3056
+ repoRoot: "/home/user/project",
3057
+ projectId: "p1",
3058
+ projectName: "My Feature",
3059
+ }),
3060
+ });
3061
+ expect(res.status).toBe(200);
3062
+ const json = await res.json();
3063
+ expect(json.mapping).toBeDefined();
3064
+ expect(json.mapping.repoRoot).toBe("/home/user/project");
3065
+ expect(json.mapping.projectName).toBe("My Feature");
3066
+ expect(linearProjectManager.upsertMapping).toHaveBeenCalledWith(
3067
+ "/home/user/project",
3068
+ { projectId: "p1", projectName: "My Feature" },
3069
+ );
3070
+ });
3071
+ });
3072
+
3073
+ describe("DELETE /api/linear/project-mappings", () => {
3074
+ it("returns 400 when repoRoot is missing", async () => {
3075
+ const res = await app.request("/api/linear/project-mappings", {
3076
+ method: "DELETE",
3077
+ headers: { "Content-Type": "application/json" },
3078
+ body: JSON.stringify({}),
3079
+ });
3080
+ expect(res.status).toBe(400);
3081
+ const json = await res.json();
3082
+ expect(json).toEqual({ error: "repoRoot is required" });
3083
+ });
3084
+
3085
+ it("returns 404 when mapping not found", async () => {
3086
+ vi.mocked(linearProjectManager.removeMapping).mockReturnValue(false);
3087
+
3088
+ const res = await app.request("/api/linear/project-mappings", {
3089
+ method: "DELETE",
3090
+ headers: { "Content-Type": "application/json" },
3091
+ body: JSON.stringify({ repoRoot: "/unknown" }),
3092
+ });
3093
+ expect(res.status).toBe(404);
3094
+ });
3095
+
3096
+ it("removes mapping successfully", async () => {
3097
+ vi.mocked(linearProjectManager.removeMapping).mockReturnValue(true);
3098
+
3099
+ const res = await app.request("/api/linear/project-mappings", {
3100
+ method: "DELETE",
3101
+ headers: { "Content-Type": "application/json" },
3102
+ body: JSON.stringify({ repoRoot: "/home/user/project" }),
3103
+ });
3104
+ expect(res.status).toBe(200);
3105
+ const json = await res.json();
3106
+ expect(json).toEqual({ ok: true });
3107
+ expect(linearProjectManager.removeMapping).toHaveBeenCalledWith("/home/user/project");
3108
+ });
3109
+ });
3110
+ // ─── Git ─────────────────────────────────────────────────────────────────────
3111
+
3112
+ describe("GET /api/git/repo-info", () => {
3113
+ it("returns repo info for a valid path", async () => {
3114
+ const info = {
3115
+ repoRoot: "/repo",
3116
+ repoName: "my-repo",
3117
+ currentBranch: "main",
3118
+ defaultBranch: "main",
3119
+ isWorktree: false,
3120
+ };
3121
+ vi.mocked(gitUtils.getRepoInfo).mockReturnValue(info);
3122
+
3123
+ const res = await app.request("/api/git/repo-info?path=/repo", { method: "GET" });
3124
+
3125
+ expect(res.status).toBe(200);
3126
+ const json = await res.json();
3127
+ expect(json).toEqual(info);
3128
+ expect(gitUtils.getRepoInfo).toHaveBeenCalledWith("/repo");
3129
+ });
3130
+
3131
+ it("returns 400 when path query parameter is missing", async () => {
3132
+ const res = await app.request("/api/git/repo-info", { method: "GET" });
3133
+
3134
+ expect(res.status).toBe(400);
3135
+ const json = await res.json();
3136
+ expect(json).toEqual({ error: "path required" });
3137
+ });
3138
+ });
3139
+
3140
+ describe("GET /api/git/branches", () => {
3141
+ it("returns branches for a repo", async () => {
3142
+ const branches = [
3143
+ { name: "main", isCurrent: true, isRemote: false, worktreePath: null, ahead: 0, behind: 0 },
3144
+ { name: "dev", isCurrent: false, isRemote: false, worktreePath: null, ahead: 2, behind: 0 },
3145
+ ];
3146
+ vi.mocked(gitUtils.listBranches).mockReturnValue(branches);
3147
+
3148
+ const res = await app.request("/api/git/branches?repoRoot=/repo", { method: "GET" });
3149
+
3150
+ expect(res.status).toBe(200);
3151
+ const json = await res.json();
3152
+ expect(json).toEqual(branches);
3153
+ expect(gitUtils.listBranches).toHaveBeenCalledWith("/repo");
3154
+ });
3155
+ });
3156
+
3157
+ describe("POST /api/git/worktree", () => {
3158
+ it("creates a worktree", async () => {
3159
+ const result = {
3160
+ worktreePath: "/home/.companion/worktrees/repo/feat",
3161
+ branch: "feat",
3162
+ actualBranch: "feat",
3163
+ isNew: true,
3164
+ };
3165
+ vi.mocked(gitUtils.ensureWorktree).mockReturnValue(result);
3166
+ const res = await app.request("/api/git/worktree", {
3167
+ method: "POST",
3168
+ headers: { "Content-Type": "application/json" },
3169
+ body: JSON.stringify({ repoRoot: "/repo", branch: "feat", baseBranch: "main" }),
3170
+ });
3171
+ expect(res.status).toBe(200);
3172
+ const json = await res.json();
3173
+ expect(json).toEqual(result);
3174
+ expect(gitUtils.ensureWorktree).toHaveBeenCalledWith("/repo", "feat", {
3175
+ baseBranch: "main",
3176
+ });
3177
+ });
3178
+ });
3179
+
3180
+ describe("DELETE /api/git/worktree", () => {
3181
+ it("removes a worktree", async () => {
3182
+ vi.mocked(gitUtils.removeWorktree).mockReturnValue({ removed: true });
3183
+ const res = await app.request("/api/git/worktree", {
3184
+ method: "DELETE",
3185
+ headers: { "Content-Type": "application/json" },
3186
+ body: JSON.stringify({ repoRoot: "/repo", worktreePath: "/wt/feat", force: true }),
3187
+ });
3188
+ expect(res.status).toBe(200);
3189
+ const json = await res.json();
3190
+ expect(json).toEqual({ removed: true });
3191
+ expect(gitUtils.removeWorktree).toHaveBeenCalledWith("/repo", "/wt/feat", { force: true });
3192
+ });
3193
+ });
3194
+
3195
+
3196
+ // ─── Session Naming ─────────────────────────────────────────────────────────
3197
+
3198
+ describe("PATCH /api/sessions/:id/name", () => {
3199
+ it("updates session name and returns ok", async () => {
3200
+ launcher.getSession.mockReturnValue({ sessionId: "s1", state: "running", cwd: "/test" });
3201
+
3202
+ const res = await app.request("/api/sessions/s1/name", {
3203
+ method: "PATCH",
3204
+ headers: { "Content-Type": "application/json" },
3205
+ body: JSON.stringify({ name: "Fix auth bug" }),
3206
+ });
3207
+
3208
+ expect(res.status).toBe(200);
3209
+ const json = await res.json();
3210
+ expect(json).toEqual({ ok: true, name: "Fix auth bug" });
3211
+ expect(sessionNames.setName).toHaveBeenCalledWith("s1", "Fix auth bug");
3212
+ // Verify the name update is broadcast to connected browsers via WebSocket
3213
+ expect(bridge.broadcastNameUpdate).toHaveBeenCalledWith("s1", "Fix auth bug");
3214
+ });
3215
+
3216
+ it("trims whitespace from name", async () => {
3217
+ launcher.getSession.mockReturnValue({ sessionId: "s1", state: "running", cwd: "/test" });
3218
+
3219
+ const res = await app.request("/api/sessions/s1/name", {
3220
+ method: "PATCH",
3221
+ headers: { "Content-Type": "application/json" },
3222
+ body: JSON.stringify({ name: " My Session " }),
3223
+ });
3224
+
3225
+ expect(res.status).toBe(200);
3226
+ const json = await res.json();
3227
+ expect(json).toEqual({ ok: true, name: "My Session" });
3228
+ expect(sessionNames.setName).toHaveBeenCalledWith("s1", "My Session");
3229
+ });
3230
+
3231
+ it("returns 404 when session not found", async () => {
3232
+ launcher.getSession.mockReturnValue(undefined);
3233
+
3234
+ const res = await app.request("/api/sessions/nonexistent/name", {
3235
+ method: "PATCH",
3236
+ headers: { "Content-Type": "application/json" },
3237
+ body: JSON.stringify({ name: "Some name" }),
3238
+ });
3239
+
3240
+ expect(res.status).toBe(404);
3241
+ const json = await res.json();
3242
+ expect(json).toEqual({ error: "Session not found" });
3243
+ });
3244
+
3245
+ it("returns 400 when name is empty", async () => {
3246
+ const res = await app.request("/api/sessions/s1/name", {
3247
+ method: "PATCH",
3248
+ headers: { "Content-Type": "application/json" },
3249
+ body: JSON.stringify({ name: "" }),
3250
+ });
3251
+
3252
+ expect(res.status).toBe(400);
3253
+ const json = await res.json();
3254
+ expect(json).toEqual({ error: "name is required" });
3255
+ });
3256
+
3257
+ it("returns 400 when name is missing", async () => {
3258
+ const res = await app.request("/api/sessions/s1/name", {
3259
+ method: "PATCH",
3260
+ headers: { "Content-Type": "application/json" },
3261
+ body: JSON.stringify({}),
3262
+ });
3263
+
3264
+ expect(res.status).toBe(400);
3265
+ });
3266
+ });
3267
+
3268
+ // ─── Update checking ────────────────────────────────────────────────────────
3269
+
3270
+ describe("GET /api/update-check", () => {
3271
+ it("triggers a refresh when never checked", async () => {
3272
+ mockUpdateCheckerState.lastChecked = 0;
3273
+
3274
+ const res = await app.request("/api/update-check", { method: "GET" });
3275
+
3276
+ expect(res.status).toBe(200);
3277
+ expect(mockCheckForUpdate).toHaveBeenCalledOnce();
3278
+ });
3279
+
3280
+ it("does not trigger a refresh when the previous check is fresh", async () => {
3281
+ mockUpdateCheckerState.lastChecked = Date.now();
3282
+
3283
+ const res = await app.request("/api/update-check", { method: "GET" });
3284
+
3285
+ expect(res.status).toBe(200);
3286
+ expect(mockCheckForUpdate).not.toHaveBeenCalled();
3287
+ });
3288
+ });
3289
+
3290
+ describe("POST /api/update-check", () => {
3291
+ it("always forces a refresh", async () => {
3292
+ mockUpdateCheckerState.lastChecked = Date.now();
3293
+
3294
+ const res = await app.request("/api/update-check", { method: "POST" });
3295
+
3296
+ expect(res.status).toBe(200);
3297
+ expect(mockCheckForUpdate).toHaveBeenCalledOnce();
3298
+ });
3299
+ });
3300
+
3301
+ // ─── Filesystem ──────────────────────────────────────────────────────────────
3302
+
3303
+ describe("GET /api/fs/home", () => {
3304
+ it("returns home directory and cwd", async () => {
3305
+ const res = await app.request("/api/fs/home", { method: "GET" });
3306
+
3307
+ expect(res.status).toBe(200);
3308
+ const json = await res.json();
3309
+ expect(json).toHaveProperty("home");
3310
+ expect(json).toHaveProperty("cwd");
3311
+ expect(typeof json.home).toBe("string");
3312
+ expect(typeof json.cwd).toBe("string");
3313
+ });
3314
+
3315
+ it("returns home as cwd when process.cwd() is the package root", async () => {
3316
+ const origCwd = process.cwd;
3317
+ const origEnv = process.env.__COMPANION_PACKAGE_ROOT;
3318
+ try {
3319
+ process.env.__COMPANION_PACKAGE_ROOT = "/opt/companion";
3320
+ process.cwd = () => "/opt/companion";
3321
+ const res = await app.request("/api/fs/home", { method: "GET" });
3322
+ const json = await res.json();
3323
+ expect(json.cwd).toBe(json.home);
3324
+ } finally {
3325
+ process.cwd = origCwd;
3326
+ process.env.__COMPANION_PACKAGE_ROOT = origEnv;
3327
+ }
3328
+ });
3329
+
3330
+ it("returns home as cwd when process.cwd() is inside the package root", async () => {
3331
+ const origCwd = process.cwd;
3332
+ const origEnv = process.env.__COMPANION_PACKAGE_ROOT;
3333
+ try {
3334
+ process.env.__COMPANION_PACKAGE_ROOT = "/opt/companion";
3335
+ process.cwd = () => "/opt/companion/node_modules/.bin";
3336
+ const res = await app.request("/api/fs/home", { method: "GET" });
3337
+ const json = await res.json();
3338
+ expect(json.cwd).toBe(json.home);
3339
+ } finally {
3340
+ process.cwd = origCwd;
3341
+ process.env.__COMPANION_PACKAGE_ROOT = origEnv;
3342
+ }
3343
+ });
3344
+
3345
+ it("returns actual cwd when launched from a project directory", async () => {
3346
+ const origCwd = process.cwd;
3347
+ const origEnv = process.env.__COMPANION_PACKAGE_ROOT;
3348
+ try {
3349
+ process.env.__COMPANION_PACKAGE_ROOT = "/opt/companion";
3350
+ process.cwd = () => "/Users/testuser/my-project";
3351
+ const res = await app.request("/api/fs/home", { method: "GET" });
3352
+ const json = await res.json();
3353
+ expect(json.cwd).toBe("/Users/testuser/my-project");
3354
+ } finally {
3355
+ process.cwd = origCwd;
3356
+ process.env.__COMPANION_PACKAGE_ROOT = origEnv;
3357
+ }
3358
+ });
3359
+
3360
+ it("returns home as cwd when process.cwd() equals home directory", async () => {
3361
+ const { homedir } = await import("node:os");
3362
+ const origCwd = process.cwd;
3363
+ const origEnv = process.env.__COMPANION_PACKAGE_ROOT;
3364
+ try {
3365
+ delete process.env.__COMPANION_PACKAGE_ROOT;
3366
+ process.cwd = () => homedir();
3367
+ const res = await app.request("/api/fs/home", { method: "GET" });
3368
+ const json = await res.json();
3369
+ expect(json.cwd).toBe(json.home);
3370
+ } finally {
3371
+ process.cwd = origCwd;
3372
+ process.env.__COMPANION_PACKAGE_ROOT = origEnv;
3373
+ }
3374
+ });
3375
+ });
3376
+
3377
+ describe("GET /api/fs/diff", () => {
3378
+ it("returns 400 when path is missing", async () => {
3379
+ const res = await app.request("/api/fs/diff", { method: "GET" });
3380
+
3381
+ expect(res.status).toBe(400);
3382
+ const json = await res.json();
3383
+ expect(json).toEqual({ error: "path required" });
3384
+ });
3385
+
3386
+ it("diffs against HEAD by default when no base param is provided", async () => {
3387
+ // Validates that /api/fs/diff defaults to HEAD (uncommitted changes only).
3388
+ const diffOutput = `diff --git a/file.ts b/file.ts
3389
+ --- a/file.ts
3390
+ +++ b/file.ts
3391
+ @@ -1,3 +1,3 @@
3392
+ line1
3393
+ -old line
3394
+ +new line
3395
+ line3`;
3396
+ vi.mocked(execSync)
3397
+ .mockReturnValueOnce("/repo\n") // rev-parse --show-toplevel
3398
+ .mockReturnValueOnce("file.ts\n") // ls-files --full-name
3399
+ .mockReturnValueOnce(diffOutput); // git diff HEAD
3400
+
3401
+ const res = await app.request("/api/fs/diff?path=/repo/file.ts", { method: "GET" });
3402
+
3403
+ expect(res.status).toBe(200);
3404
+ const json = await res.json();
3405
+ expect(json.diff).toBe(diffOutput);
3406
+ expect(json.path).toContain("file.ts");
3407
+ expect(vi.mocked(execSync)).toHaveBeenCalledWith(
3408
+ expect.stringContaining("git diff HEAD"),
3409
+ expect.objectContaining({ encoding: "utf-8", timeout: 5000 }),
3410
+ );
3411
+ });
3412
+
3413
+ it("diffs against default branch when base=default-branch", async () => {
3414
+ // Validates that /api/fs/diff uses the repository default branch as base (origin/main here).
3415
+ const diffOutput = `diff --git a/file.ts b/file.ts
3416
+ --- a/file.ts
3417
+ +++ b/file.ts
3418
+ @@ -1,3 +1,3 @@
3419
+ line1
3420
+ -old line
3421
+ +new line
3422
+ line3`;
3423
+ vi.mocked(execSync)
3424
+ .mockReturnValueOnce("/repo\n") // rev-parse --show-toplevel
3425
+ .mockReturnValueOnce("file.ts\n") // ls-files --full-name
3426
+ .mockReturnValueOnce("refs/remotes/origin/main\n") // symbolic-ref refs/remotes/origin/HEAD
3427
+ .mockReturnValueOnce(diffOutput); // git diff origin/main
3428
+
3429
+ const res = await app.request("/api/fs/diff?path=/repo/file.ts&base=default-branch", { method: "GET" });
3430
+
3431
+ expect(res.status).toBe(200);
3432
+ const json = await res.json();
3433
+ expect(json.diff).toBe(diffOutput);
3434
+ expect(json.path).toContain("file.ts");
3435
+ expect(vi.mocked(execSync)).toHaveBeenCalledWith(
3436
+ expect.stringContaining("git diff origin/main"),
3437
+ expect.objectContaining({ encoding: "utf-8", timeout: 5000 }),
3438
+ );
3439
+ });
3440
+
3441
+ it("returns no-index diff for untracked files", async () => {
3442
+ // Untracked files have no base-branch diff content, so API must fallback to a full-file no-index diff.
3443
+ const untrackedDiff = `diff --git a/new.txt b/new.txt
3444
+ new file mode 100644
3445
+ index 0000000..e69de29
3446
+ --- /dev/null
3447
+ +++ b/new.txt
3448
+ @@ -0,0 +1 @@
3449
+ +hello`;
3450
+
3451
+ vi.mocked(execSync)
3452
+ .mockReturnValueOnce("/repo\n") // rev-parse --show-toplevel
3453
+ .mockReturnValueOnce("new.txt\n") // ls-files --full-name
3454
+ .mockReturnValueOnce("refs/remotes/origin/main\n") // symbolic-ref refs/remotes/origin/HEAD
3455
+ .mockReturnValueOnce("") // git diff origin/main -> empty for untracked
3456
+ .mockReturnValueOnce("new.txt\n") // ls-files --others --exclude-standard
3457
+ .mockImplementationOnce(() => {
3458
+ const err = new Error("diff exits with 1 for differences") as Error & { stdout: string };
3459
+ err.stdout = untrackedDiff;
3460
+ throw err;
3461
+ }); // git diff --no-index
3462
+
3463
+ const res = await app.request("/api/fs/diff?path=/repo/new.txt&base=default-branch", { method: "GET" });
3464
+ const json = await res.json();
3465
+
3466
+ expect(res.status).toBe(200);
3467
+ expect(json.diff).toContain("new file mode");
3468
+ expect(vi.mocked(execSync)).toHaveBeenCalledWith(
3469
+ expect.stringContaining("git diff --no-index -- /dev/null"),
3470
+ expect.objectContaining({ encoding: "utf-8", timeout: 5000 }),
3471
+ );
3472
+ });
3473
+
3474
+ it("falls back to local default branch when origin HEAD is unavailable", async () => {
3475
+ // Ensures fallback chain works when symbolic-ref fails (e.g. no origin/HEAD): use local fallback branch.
3476
+ const diffOutput = `diff --git a/file.ts b/file.ts
3477
+ --- a/file.ts
3478
+ +++ b/file.ts
3479
+ @@ -1,2 +1,3 @@
3480
+ line1
3481
+ +added`;
3482
+ vi.mocked(execSync)
3483
+ .mockReturnValueOnce("/repo\n") // rev-parse --show-toplevel
3484
+ .mockReturnValueOnce("file.ts\n") // ls-files --full-name
3485
+ .mockImplementationOnce(() => {
3486
+ const err = new Error("no symbol ref") as Error & { stdout: string };
3487
+ err.stdout = "error: ref refs/remotes/origin/HEAD is not a symbolic ref";
3488
+ throw err;
3489
+ }) // symbolic-ref refs/remotes/origin/HEAD unavailable
3490
+ .mockReturnValueOnce("main\n") // branch --list fallback
3491
+ .mockReturnValueOnce(diffOutput); // git diff main
3492
+
3493
+ const res = await app.request("/api/fs/diff?path=/repo/file.ts&base=default-branch", { method: "GET" });
3494
+
3495
+ expect(res.status).toBe(200);
3496
+ const json = await res.json();
3497
+ expect(json.diff).toBe(diffOutput);
3498
+ expect(vi.mocked(execSync)).toHaveBeenCalledWith(
3499
+ expect.stringContaining("git diff main"),
3500
+ expect.objectContaining({ encoding: "utf-8", timeout: 5000 }),
3501
+ );
3502
+ });
3503
+
3504
+ it("returns empty diff when git command fails", async () => {
3505
+ vi.mocked(execSync).mockImplementationOnce(() => {
3506
+ throw new Error("not a git repository");
3507
+ });
3508
+
3509
+ const res = await app.request("/api/fs/diff?path=/not-a-repo/file.ts", { method: "GET" });
3510
+
3511
+ expect(res.status).toBe(200);
3512
+ const json = await res.json();
3513
+ expect(json.diff).toBe("");
3514
+ expect(json.path).toContain("file.ts");
3515
+ });
3516
+ });
3517
+
3518
+ // ─── Backends ─────────────────────────────────────────────────────────────────
3519
+
3520
+ describe("GET /api/backends", () => {
3521
+ it("returns both backends with availability status", async () => {
3522
+ // resolveBinary returns a path for both binaries
3523
+ mockResolveBinary
3524
+ .mockReturnValueOnce("/usr/bin/claude")
3525
+ .mockReturnValueOnce("/usr/bin/codex");
3526
+
3527
+ const res = await app.request("/api/backends", { method: "GET" });
3528
+
3529
+ expect(res.status).toBe(200);
3530
+ const json = await res.json();
3531
+ expect(json).toEqual([
3532
+ { id: "claude", name: "Claude Code", available: true },
3533
+ { id: "codex", name: "Codex", available: true },
3534
+ ]);
3535
+ });
3536
+
3537
+ it("marks backends as unavailable when binary is not found", async () => {
3538
+ // resolveBinary returns null for both
3539
+ mockResolveBinary
3540
+ .mockReturnValueOnce(null)
3541
+ .mockReturnValueOnce(null);
3542
+
3543
+ const res = await app.request("/api/backends", { method: "GET" });
3544
+
3545
+ expect(res.status).toBe(200);
3546
+ const json = await res.json();
3547
+ expect(json).toEqual([
3548
+ { id: "claude", name: "Claude Code", available: false },
3549
+ { id: "codex", name: "Codex", available: false },
3550
+ ]);
3551
+ });
3552
+
3553
+ it("handles mixed availability", async () => {
3554
+ mockResolveBinary
3555
+ .mockReturnValueOnce("/usr/bin/claude") // claude found
3556
+ .mockReturnValueOnce(null); // codex not found
3557
+
3558
+ const res = await app.request("/api/backends", { method: "GET" });
3559
+
3560
+ expect(res.status).toBe(200);
3561
+ const json = await res.json();
3562
+ expect(json[0].available).toBe(true);
3563
+ expect(json[1].available).toBe(false);
3564
+ });
3565
+ });
3566
+
3567
+ describe("GET /api/backends/:id/models", () => {
3568
+ it("returns codex models from cache file sorted by priority", async () => {
3569
+ const cacheContent = JSON.stringify({
3570
+ models: [
3571
+ { slug: "gpt-5.1-codex-mini", display_name: "gpt-5.1-codex-mini", description: "Fast model", visibility: "list", priority: 10 },
3572
+ { slug: "gpt-5.2-codex", display_name: "gpt-5.2-codex", description: "Frontier model", visibility: "list", priority: 0 },
3573
+ { slug: "gpt-5-codex", display_name: "gpt-5-codex", description: "Old model", visibility: "hide", priority: 8 },
3574
+ ],
3575
+ });
3576
+ vi.mocked(existsSync).mockReturnValue(true);
3577
+ vi.mocked(readFileSync).mockReturnValue(cacheContent);
3578
+
3579
+ const res = await app.request("/api/backends/codex/models", { method: "GET" });
3580
+
3581
+ expect(res.status).toBe(200);
3582
+ const json = await res.json();
3583
+ // Should only include visible models, sorted by priority
3584
+ expect(json).toEqual([
3585
+ { value: "gpt-5.2-codex", label: "gpt-5.2-codex", description: "Frontier model" },
3586
+ { value: "gpt-5.1-codex-mini", label: "gpt-5.1-codex-mini", description: "Fast model" },
3587
+ ]);
3588
+ });
3589
+
3590
+ it("returns 404 when codex cache file does not exist", async () => {
3591
+ vi.mocked(existsSync).mockReturnValue(false);
3592
+
3593
+ const res = await app.request("/api/backends/codex/models", { method: "GET" });
3594
+
3595
+ expect(res.status).toBe(404);
3596
+ const json = await res.json();
3597
+ expect(json.error).toContain("Codex models cache not found");
3598
+ });
3599
+
3600
+ it("returns 500 when cache file is malformed", async () => {
3601
+ vi.mocked(existsSync).mockReturnValue(true);
3602
+ vi.mocked(readFileSync).mockReturnValue("not valid json{{{");
3603
+
3604
+ const res = await app.request("/api/backends/codex/models", { method: "GET" });
3605
+
3606
+ expect(res.status).toBe(500);
3607
+ const json = await res.json();
3608
+ expect(json.error).toContain("Failed to parse");
3609
+ });
3610
+
3611
+ it("returns 404 for claude backend (uses frontend defaults)", async () => {
3612
+ const res = await app.request("/api/backends/claude/models", { method: "GET" });
3613
+
3614
+ expect(res.status).toBe(404);
3615
+ });
3616
+ });
3617
+
3618
+ // ─── Session creation with backend type ──────────────────────────────────────
3619
+
3620
+ describe("POST /api/sessions/create with backend", () => {
3621
+ // Route delegates to orchestrator.createSession — backend resolution is tested
3622
+ // in session-orchestrator.test.ts. Route tests verify the body is passed through.
3623
+
3624
+ it("passes backend codex through to orchestrator", async () => {
3625
+ const res = await app.request("/api/sessions/create", {
3626
+ method: "POST",
3627
+ headers: { "Content-Type": "application/json" },
3628
+ body: JSON.stringify({ model: "gpt-5.2-codex", cwd: "/test", backend: "codex" }),
3629
+ });
3630
+
3631
+ expect(res.status).toBe(200);
3632
+ expect(orchestrator.createSession).toHaveBeenCalledWith(
3633
+ expect.objectContaining({ model: "gpt-5.2-codex", backend: "codex" }),
3634
+ );
3635
+ });
3636
+
3637
+ it("passes request without backend to orchestrator (defaults handled by orchestrator)", async () => {
3638
+ const res = await app.request("/api/sessions/create", {
3639
+ method: "POST",
3640
+ headers: { "Content-Type": "application/json" },
3641
+ body: JSON.stringify({ cwd: "/test" }),
3642
+ });
3643
+
3644
+ expect(res.status).toBe(200);
3645
+ expect(orchestrator.createSession).toHaveBeenCalledWith(
3646
+ expect.objectContaining({ cwd: "/test" }),
3647
+ );
3648
+ });
3649
+ });
3650
+
3651
+ // ─── Per-session usage limits ─────────────────────────────────────────────────
3652
+
3653
+ describe("GET /api/sessions/:id/usage-limits", () => {
3654
+ it("returns Claude usage limits for a claude session", async () => {
3655
+ bridge.getSession.mockReturnValue({ backendType: "claude" });
3656
+ mockGetUsageLimits.mockResolvedValue({
3657
+ five_hour: { utilization: 42, resets_at: "2025-01-01T12:00:00Z" },
3658
+ seven_day: { utilization: 15, resets_at: null },
3659
+ extra_usage: null,
3660
+ });
3661
+
3662
+ const res = await app.request("/api/sessions/s1/usage-limits", { method: "GET" });
3663
+
3664
+ expect(res.status).toBe(200);
3665
+ const json = await res.json();
3666
+ expect(json).toEqual({
3667
+ five_hour: { utilization: 42, resets_at: "2025-01-01T12:00:00Z" },
3668
+ seven_day: { utilization: 15, resets_at: null },
3669
+ extra_usage: null,
3670
+ });
3671
+ expect(mockGetUsageLimits).toHaveBeenCalled();
3672
+ });
3673
+
3674
+ it("returns mapped Codex rate limits for a codex session", async () => {
3675
+ bridge.getSession.mockReturnValue({ backendType: "codex" });
3676
+ bridge.getCodexRateLimits.mockReturnValue({
3677
+ primary: { usedPercent: 25, windowDurationMins: 300, resetsAt: 1730947200 * 1000 },
3678
+ secondary: { usedPercent: 10, windowDurationMins: 10080, resetsAt: 1731552000 * 1000 },
3679
+ });
3680
+
3681
+ const res = await app.request("/api/sessions/s1/usage-limits", { method: "GET" });
3682
+
3683
+ expect(res.status).toBe(200);
3684
+ const json = await res.json();
3685
+ expect(json.five_hour).toEqual({
3686
+ utilization: 25,
3687
+ resets_at: new Date(1730947200 * 1000).toISOString(),
3688
+ });
3689
+ expect(json.seven_day).toEqual({
3690
+ utilization: 10,
3691
+ resets_at: new Date(1731552000 * 1000).toISOString(),
3692
+ });
3693
+ expect(json.extra_usage).toBeNull();
3694
+ expect(mockGetUsageLimits).not.toHaveBeenCalled();
3695
+ });
3696
+
3697
+ it("returns empty limits when codex session has no rate limits yet", async () => {
3698
+ bridge.getSession.mockReturnValue({ backendType: "codex" });
3699
+ bridge.getCodexRateLimits.mockReturnValue(null);
3700
+
3701
+ const res = await app.request("/api/sessions/s1/usage-limits", { method: "GET" });
3702
+
3703
+ expect(res.status).toBe(200);
3704
+ const json = await res.json();
3705
+ expect(json).toEqual({ five_hour: null, seven_day: null, extra_usage: null });
3706
+ });
3707
+
3708
+ it("maps Codex rate limits when bridge still returns second-based timestamps", async () => {
3709
+ bridge.getSession.mockReturnValue({ backendType: "codex" });
3710
+ bridge.getCodexRateLimits.mockReturnValue({
3711
+ // Backward-compat coverage for pre-normalized payloads from bridge/session state.
3712
+ primary: { usedPercent: 25, windowDurationMins: 300, resetsAt: 1730947200 },
3713
+ secondary: { usedPercent: 10, windowDurationMins: 10080, resetsAt: 1731552000 },
3714
+ });
3715
+
3716
+ const res = await app.request("/api/sessions/s1/usage-limits", { method: "GET" });
3717
+
3718
+ expect(res.status).toBe(200);
3719
+ const json = await res.json();
3720
+ expect(json.five_hour).toEqual({
3721
+ utilization: 25,
3722
+ resets_at: new Date(1730947200 * 1000).toISOString(),
3723
+ });
3724
+ expect(json.seven_day).toEqual({
3725
+ utilization: 10,
3726
+ resets_at: new Date(1731552000 * 1000).toISOString(),
3727
+ });
3728
+ });
3729
+
3730
+ it("handles codex rate limits with null secondary", async () => {
3731
+ bridge.getSession.mockReturnValue({ backendType: "codex" });
3732
+ bridge.getCodexRateLimits.mockReturnValue({
3733
+ primary: { usedPercent: 50, windowDurationMins: 300, resetsAt: 0 },
3734
+ secondary: null,
3735
+ });
3736
+
3737
+ const res = await app.request("/api/sessions/s1/usage-limits", { method: "GET" });
3738
+
3739
+ expect(res.status).toBe(200);
3740
+ const json = await res.json();
3741
+ expect(json.five_hour).toEqual({ utilization: 50, resets_at: null });
3742
+ expect(json.seven_day).toBeNull();
3743
+ });
3744
+
3745
+ it("falls back to Claude limits when session is not found", async () => {
3746
+ bridge.getSession.mockReturnValue(null);
3747
+ mockGetUsageLimits.mockResolvedValue({
3748
+ five_hour: null,
3749
+ seven_day: null,
3750
+ extra_usage: null,
3751
+ });
3752
+
3753
+ const res = await app.request("/api/sessions/unknown/usage-limits", { method: "GET" });
3754
+
3755
+ expect(res.status).toBe(200);
3756
+ const json = await res.json();
3757
+ expect(json).toEqual({ five_hour: null, seven_day: null, extra_usage: null });
3758
+ expect(mockGetUsageLimits).toHaveBeenCalled();
3759
+ });
3760
+ });
3761
+
3762
+ // ─── SSE Session Creation Streaming ──────────────────────────────────────────
3763
+
3764
+ /** Parse an SSE response body into an array of {event, data} objects */
3765
+ async function parseSSE(res: Response): Promise<{ event: string; data: string }[]> {
3766
+ const text = await res.text();
3767
+ const events: { event: string; data: string }[] = [];
3768
+ // SSE frames are separated by double newlines
3769
+ for (const block of text.split("\n\n")) {
3770
+ const trimmed = block.trim();
3771
+ if (!trimmed) continue;
3772
+ let event = "message";
3773
+ let data = "";
3774
+ for (const line of trimmed.split("\n")) {
3775
+ if (line.startsWith("event:")) event = line.slice(6).trim();
3776
+ else if (line.startsWith("data:")) data = line.slice(5).trim();
3777
+ }
3778
+ if (data) events.push({ event, data });
3779
+ }
3780
+ return events;
3781
+ }
3782
+
3783
+ describe("POST /api/sessions/create-stream", () => {
3784
+ // Route delegates to orchestrator.createSessionStreaming — detailed orchestration logic
3785
+ // (git ops, container creation, image pulling, etc.) is tested in session-orchestrator.test.ts.
3786
+ // Route tests verify SSE transport: progress events are emitted, done/error events are correct.
3787
+
3788
+ it("emits progress events from orchestrator and done event on success", async () => {
3789
+ // Mock createSessionStreaming to call the progress callback with some events
3790
+ orchestrator.createSessionStreaming.mockImplementation(async (_body: any, onProgress: any) => {
3791
+ await onProgress("resolving_env", "Resolving environment...", "in_progress");
3792
+ await onProgress("resolving_env", "Resolving environment...", "done");
3793
+ await onProgress("launching_cli", "Launching Claude Code...", "in_progress");
3794
+ await onProgress("launching_cli", "Launching Claude Code...", "done");
3795
+ return {
3796
+ ok: true,
3797
+ session: { sessionId: "session-1", state: "starting", cwd: "/test", createdAt: Date.now(), backendType: "claude" },
3798
+ };
3799
+ });
3800
+
3801
+ const res = await app.request("/api/sessions/create-stream", {
3802
+ method: "POST",
3803
+ headers: { "Content-Type": "application/json" },
3804
+ body: JSON.stringify({ cwd: "/test" }),
3805
+ });
3806
+
3807
+ expect(res.status).toBe(200);
3808
+ expect(res.headers.get("content-type")).toContain("text/event-stream");
3809
+
3810
+ const events = await parseSSE(res);
3811
+
3812
+ // Should have progress events
3813
+ const progressEvents = events.filter((e) => e.event === "progress");
3814
+ expect(progressEvents.length).toBe(4);
3815
+
3816
+ // First progress should be resolving_env in_progress
3817
+ const first = JSON.parse(progressEvents[0].data);
3818
+ expect(first.step).toBe("resolving_env");
3819
+ expect(first.status).toBe("in_progress");
3820
+
3821
+ // Done event should be emitted with session info
3822
+ const doneEvent = events.find((e) => e.event === "done");
3823
+ expect(doneEvent).toBeDefined();
3824
+ const doneData = JSON.parse(doneEvent!.data);
3825
+ expect(doneData.sessionId).toBe("session-1");
3826
+ expect(doneData.cwd).toBe("/test");
3827
+ });
3828
+
3829
+ it("emits error event when orchestrator returns failure", async () => {
3830
+ orchestrator.createSessionStreaming.mockResolvedValue({
3831
+ ok: false,
3832
+ error: "Invalid backend: invalid",
3833
+ status: 400,
3834
+ });
3835
+
3836
+ const res = await app.request("/api/sessions/create-stream", {
3837
+ method: "POST",
3838
+ headers: { "Content-Type": "application/json" },
3839
+ body: JSON.stringify({ cwd: "/test", backend: "invalid" }),
3840
+ });
3841
+
3842
+ expect(res.status).toBe(200);
3843
+ const events = await parseSSE(res);
3844
+ const errorEvent = events.find((e) => e.event === "error");
3845
+ expect(errorEvent).toBeDefined();
3846
+ expect(JSON.parse(errorEvent!.data).error).toContain("Invalid backend");
3847
+ });
3848
+
3849
+ it("passes request body through to orchestrator", async () => {
3850
+ const body = {
3851
+ cwd: "/test",
3852
+ backend: "codex",
3853
+ branch: "feat/new",
3854
+ useWorktree: true,
3855
+ sandboxEnabled: true,
3856
+ sandboxSlug: "docker",
3857
+ };
3858
+
3859
+ const res = await app.request("/api/sessions/create-stream", {
3860
+ method: "POST",
3861
+ headers: { "Content-Type": "application/json" },
3862
+ body: JSON.stringify(body),
3863
+ });
3864
+
3865
+ expect(res.status).toBe(200);
3866
+ expect(orchestrator.createSessionStreaming).toHaveBeenCalledWith(
3867
+ body,
3868
+ expect.any(Function),
3869
+ );
3870
+ });
3871
+
3872
+ it("does not emit done event when orchestrator returns error", async () => {
3873
+ orchestrator.createSessionStreaming.mockImplementation(async (_body: any, onProgress: any) => {
3874
+ await onProgress("resolving_env", "Resolving environment...", "in_progress");
3875
+ return { ok: false, error: "CLI binary not found", status: 500 };
3876
+ });
3877
+
3878
+ const res = await app.request("/api/sessions/create-stream", {
3879
+ method: "POST",
3880
+ headers: { "Content-Type": "application/json" },
3881
+ body: JSON.stringify({ cwd: "/test" }),
3882
+ });
3883
+
3884
+ const events = await parseSSE(res);
3885
+ const doneEvent = events.find((e) => e.event === "done");
3886
+ expect(doneEvent).toBeUndefined();
3887
+ const errorEvent = events.find((e) => e.event === "error");
3888
+ expect(errorEvent).toBeDefined();
3889
+ });
3890
+ });
3891
+
3892
+ // ---------------------------------------------------------------------------
3893
+ // Auth endpoints
3894
+ // ---------------------------------------------------------------------------
3895
+
3896
+ describe("POST /api/auth/verify", () => {
3897
+ it("returns ok:true for valid token", async () => {
3898
+ // verifyToken is mocked to return true, so any token should succeed
3899
+ const res = await app.request("/api/auth/verify", {
3900
+ method: "POST",
3901
+ headers: { "Content-Type": "application/json" },
3902
+ body: JSON.stringify({ token: "test-token-for-routes" }),
3903
+ });
3904
+ expect(res.status).toBe(200);
3905
+ const data = await res.json();
3906
+ expect(data.ok).toBe(true);
3907
+ });
3908
+
3909
+ it("returns 401 for invalid token", async () => {
3910
+ // Temporarily override verifyToken to reject
3911
+ const { verifyToken } = await import("./auth-manager.js");
3912
+ (verifyToken as ReturnType<typeof vi.fn>).mockReturnValueOnce(false);
3913
+
3914
+ const res = await app.request("/api/auth/verify", {
3915
+ method: "POST",
3916
+ headers: { "Content-Type": "application/json" },
3917
+ body: JSON.stringify({ token: "wrong" }),
3918
+ });
3919
+ expect(res.status).toBe(401);
3920
+ const data = await res.json();
3921
+ expect(data.error).toContain("Invalid token");
3922
+ });
3923
+ });
3924
+
3925
+ // ---------------------------------------------------------------------------
3926
+ // Container status / images endpoints
3927
+ // ---------------------------------------------------------------------------
3928
+
3929
+ describe("GET /api/containers/status", () => {
3930
+ it("returns docker availability and version", async () => {
3931
+ // containerManager is already imported and its methods can be spied on
3932
+ const checkSpy = vi.spyOn(containerManager, "checkDocker").mockReturnValue(true);
3933
+ const versionSpy = vi.spyOn(containerManager, "getDockerVersion").mockReturnValue("24.0.7");
3934
+
3935
+ const res = await app.request("/api/containers/status");
3936
+ expect(res.status).toBe(200);
3937
+ const data = await res.json();
3938
+ expect(data.available).toBe(true);
3939
+ expect(data.version).toBe("24.0.7");
3940
+
3941
+ checkSpy.mockRestore();
3942
+ versionSpy.mockRestore();
3943
+ });
3944
+
3945
+ it("returns null version when docker is unavailable", async () => {
3946
+ const checkSpy = vi.spyOn(containerManager, "checkDocker").mockReturnValue(false);
3947
+
3948
+ const res = await app.request("/api/containers/status");
3949
+ expect(res.status).toBe(200);
3950
+ const data = await res.json();
3951
+ expect(data.available).toBe(false);
3952
+ expect(data.version).toBeNull();
3953
+
3954
+ checkSpy.mockRestore();
3955
+ });
3956
+ });
3957
+
3958
+ describe("GET /api/containers/images", () => {
3959
+ it("returns list of available images", async () => {
3960
+ const spy = vi.spyOn(containerManager, "listImages").mockReturnValue(["node:22", "ubuntu:latest"]);
3961
+
3962
+ const res = await app.request("/api/containers/images");
3963
+ expect(res.status).toBe(200);
3964
+ const data = await res.json();
3965
+ expect(data).toEqual(["node:22", "ubuntu:latest"]);
3966
+
3967
+ spy.mockRestore();
3968
+ });
3969
+ });
3970
+
3971
+ // ---------------------------------------------------------------------------
3972
+ // Recording management endpoints (recorder=undefined by default)
3973
+ // ---------------------------------------------------------------------------
3974
+
3975
+ describe("Recording endpoints (no recorder)", () => {
3976
+ it("POST /api/sessions/:id/recording/start returns 501 when recorder is not available", async () => {
3977
+ // Default test setup doesn't pass a recorder to createRoutes
3978
+ const res = await app.request("/api/sessions/sess-1/recording/start", { method: "POST" });
3979
+ expect(res.status).toBe(501);
3980
+ const data = await res.json();
3981
+ expect(data.error).toContain("Recording not available");
3982
+ });
3983
+
3984
+ it("POST /api/sessions/:id/recording/stop returns 501 when recorder is not available", async () => {
3985
+ const res = await app.request("/api/sessions/sess-1/recording/stop", { method: "POST" });
3986
+ expect(res.status).toBe(501);
3987
+ const data = await res.json();
3988
+ expect(data.error).toContain("Recording not available");
3989
+ });
3990
+
3991
+ it("GET /api/sessions/:id/recording/status returns unavailable when no recorder", async () => {
3992
+ const res = await app.request("/api/sessions/sess-1/recording/status");
3993
+ expect(res.status).toBe(200);
3994
+ const data = await res.json();
3995
+ expect(data.recording).toBe(false);
3996
+ expect(data.available).toBe(false);
3997
+ });
3998
+
3999
+ it("GET /api/recordings returns empty list when no recorder", async () => {
4000
+ const res = await app.request("/api/recordings");
4001
+ expect(res.status).toBe(200);
4002
+ const data = await res.json();
4003
+ expect(data.recordings).toEqual([]);
4004
+ });
4005
+ });
4006
+
4007
+ // ---------------------------------------------------------------------------
4008
+ // Process kill endpoints
4009
+ // ---------------------------------------------------------------------------
4010
+
4011
+ describe("POST /api/sessions/:id/processes/:taskId/kill", () => {
4012
+ it("returns 400 for invalid task ID format", async () => {
4013
+ // Task IDs must be hex strings
4014
+ launcher.getSession.mockReturnValue({ pid: 1234 });
4015
+ const res = await app.request("/api/sessions/sess-1/processes/not-hex!/kill", {
4016
+ method: "POST",
4017
+ });
4018
+ expect(res.status).toBe(400);
4019
+ const data = await res.json();
4020
+ expect(data.error).toContain("Invalid task ID");
4021
+ });
4022
+
4023
+ it("returns 404 when session does not exist", async () => {
4024
+ launcher.getSession.mockReturnValue(undefined);
4025
+ const res = await app.request("/api/sessions/nonexistent/processes/abcdef/kill", {
4026
+ method: "POST",
4027
+ });
4028
+ expect(res.status).toBe(404);
4029
+ });
4030
+
4031
+ it("returns 503 when session PID is unknown", async () => {
4032
+ launcher.getSession.mockReturnValue({ pid: null });
4033
+ const res = await app.request("/api/sessions/sess-1/processes/abcdef/kill", {
4034
+ method: "POST",
4035
+ });
4036
+ expect(res.status).toBe(503);
4037
+ });
4038
+
4039
+ it("kills process in container when session has containerId", async () => {
4040
+ launcher.getSession.mockReturnValue({ pid: 1234, containerId: "cid123" });
4041
+ const execSpy = vi.spyOn(containerManager, "execInContainer").mockReturnValue("");
4042
+
4043
+ const res = await app.request("/api/sessions/sess-1/processes/abcdef/kill", {
4044
+ method: "POST",
4045
+ });
4046
+ expect(res.status).toBe(200);
4047
+ const data = await res.json();
4048
+ expect(data.ok).toBe(true);
4049
+ expect(execSpy).toHaveBeenCalled();
4050
+
4051
+ execSpy.mockRestore();
4052
+ });
4053
+
4054
+ it("kills process on host when session has no container", async () => {
4055
+ launcher.getSession.mockReturnValue({ pid: 1234 });
4056
+ // execFileSync is mocked at module level — the endpoint uses dynamic import
4057
+ const res = await app.request("/api/sessions/sess-1/processes/abcdef/kill", {
4058
+ method: "POST",
4059
+ });
4060
+ expect(res.status).toBe(200);
4061
+ const data = await res.json();
4062
+ expect(data.ok).toBe(true);
4063
+ });
4064
+ });
4065
+
4066
+ describe("POST /api/sessions/:id/processes/kill-all", () => {
4067
+ it("returns 404 when session does not exist", async () => {
4068
+ launcher.getSession.mockReturnValue(undefined);
4069
+ const res = await app.request("/api/sessions/nonexistent/processes/kill-all", {
4070
+ method: "POST",
4071
+ headers: { "Content-Type": "application/json" },
4072
+ body: JSON.stringify({ taskIds: ["abc123"] }),
4073
+ });
4074
+ expect(res.status).toBe(404);
4075
+ });
4076
+
4077
+ it("rejects invalid task IDs and processes valid ones", async () => {
4078
+ launcher.getSession.mockReturnValue({ pid: 1234 });
4079
+ const res = await app.request("/api/sessions/sess-1/processes/kill-all", {
4080
+ method: "POST",
4081
+ headers: { "Content-Type": "application/json" },
4082
+ body: JSON.stringify({ taskIds: ["abc123", "not-valid!"] }),
4083
+ });
4084
+ expect(res.status).toBe(200);
4085
+ const data = await res.json();
4086
+ expect(data.ok).toBe(true);
4087
+ expect(data.results).toHaveLength(2);
4088
+ // First one should succeed, second should fail validation
4089
+ expect(data.results[0].ok).toBe(true);
4090
+ expect(data.results[1].ok).toBe(false);
4091
+ expect(data.results[1].error).toContain("Invalid task ID");
4092
+ });
4093
+
4094
+ it("kills processes in container when session has containerId", async () => {
4095
+ launcher.getSession.mockReturnValue({ pid: 1234, containerId: "cid123" });
4096
+ const execSpy = vi.spyOn(containerManager, "execInContainer").mockReturnValue("");
4097
+
4098
+ const res = await app.request("/api/sessions/sess-1/processes/kill-all", {
4099
+ method: "POST",
4100
+ headers: { "Content-Type": "application/json" },
4101
+ body: JSON.stringify({ taskIds: ["abc123"] }),
4102
+ });
4103
+ expect(res.status).toBe(200);
4104
+ const data = await res.json();
4105
+ expect(data.results[0].ok).toBe(true);
4106
+ expect(execSpy).toHaveBeenCalled();
4107
+
4108
+ execSpy.mockRestore();
4109
+ });
4110
+ });
4111
+
4112
+ // ---------------------------------------------------------------------------
4113
+ // System process kill endpoint
4114
+ // ---------------------------------------------------------------------------
4115
+
4116
+ describe("POST /api/sessions/:id/processes/system/:pid/kill", () => {
4117
+ it("returns 400 for invalid PID", async () => {
4118
+ const res = await app.request("/api/sessions/sess-1/processes/system/notanumber/kill", {
4119
+ method: "POST",
4120
+ });
4121
+ expect(res.status).toBe(400);
4122
+ const data = await res.json();
4123
+ expect(data.error).toContain("Invalid PID");
4124
+ });
4125
+
4126
+ it("returns 404 when session does not exist", async () => {
4127
+ launcher.getSession.mockReturnValue(undefined);
4128
+ const res = await app.request("/api/sessions/sess-1/processes/system/9999/kill", {
4129
+ method: "POST",
4130
+ });
4131
+ expect(res.status).toBe(404);
4132
+ });
4133
+
4134
+ it("refuses to kill the companion server process", async () => {
4135
+ launcher.getSession.mockReturnValue({ pid: 1234 });
4136
+ const res = await app.request(`/api/sessions/sess-1/processes/system/${process.pid}/kill`, {
4137
+ method: "POST",
4138
+ });
4139
+ expect(res.status).toBe(403);
4140
+ const data = await res.json();
4141
+ expect(data.error).toContain("Cannot kill the Companion server");
4142
+ });
4143
+
4144
+ it("refuses to kill the session's own CLI process", async () => {
4145
+ launcher.getSession.mockReturnValue({ pid: 5678 });
4146
+ const res = await app.request("/api/sessions/sess-1/processes/system/5678/kill", {
4147
+ method: "POST",
4148
+ });
4149
+ expect(res.status).toBe(403);
4150
+ const data = await res.json();
4151
+ expect(data.error).toContain("Use the session kill endpoint");
4152
+ });
4153
+
4154
+ it("kills process in container when session has containerId", async () => {
4155
+ launcher.getSession.mockReturnValue({ pid: 1234, containerId: "cid123" });
4156
+ const execSpy = vi.spyOn(containerManager, "execInContainer").mockReturnValue("");
4157
+
4158
+ const res = await app.request("/api/sessions/sess-1/processes/system/9999/kill", {
4159
+ method: "POST",
4160
+ });
4161
+ expect(res.status).toBe(200);
4162
+ const data = await res.json();
4163
+ expect(data.ok).toBe(true);
4164
+ expect(execSpy).toHaveBeenCalledWith(
4165
+ "cid123",
4166
+ ["kill", "-TERM", "9999"],
4167
+ 5_000,
4168
+ );
4169
+
4170
+ execSpy.mockRestore();
4171
+ });
4172
+
4173
+ it("kills process on host when session has no container", async () => {
4174
+ launcher.getSession.mockReturnValue({ pid: 1234 });
4175
+ const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
4176
+
4177
+ const res = await app.request("/api/sessions/sess-1/processes/system/9999/kill", {
4178
+ method: "POST",
4179
+ });
4180
+ expect(res.status).toBe(200);
4181
+ const data = await res.json();
4182
+ expect(data.ok).toBe(true);
4183
+
4184
+ killSpy.mockRestore();
4185
+ });
4186
+ });
4187
+
4188
+ // ── Browser preview endpoints ─────────────────────────────────────────────────
4189
+
4190
+ describe("POST /api/sessions/:id/browser/start", () => {
4191
+ it("returns host mode for non-container sessions", async () => {
4192
+ launcher.getSession.mockReturnValue({
4193
+ sessionId: "s1",
4194
+ state: "running",
4195
+ cwd: "/repo",
4196
+ });
4197
+
4198
+ const res = await app.request("/api/sessions/s1/browser/start", { method: "POST" });
4199
+
4200
+ expect(res.status).toBe(200);
4201
+ const json = await res.json();
4202
+ expect(json).toMatchObject({
4203
+ available: true,
4204
+ mode: "host",
4205
+ });
4206
+ });
4207
+
4208
+ it("returns unavailable when container is missing", async () => {
4209
+ launcher.getSession.mockReturnValue({
4210
+ sessionId: "s1",
4211
+ state: "running",
4212
+ cwd: "/repo",
4213
+ containerId: "cid-1",
4214
+ });
4215
+ vi.spyOn(containerManager, "getContainer").mockReturnValue(undefined);
4216
+
4217
+ const res = await app.request("/api/sessions/s1/browser/start", { method: "POST" });
4218
+
4219
+ expect(res.status).toBe(200);
4220
+ const json = await res.json();
4221
+ expect(json).toMatchObject({
4222
+ available: false,
4223
+ mode: "container",
4224
+ });
4225
+ expect(json.message).toContain("Container not found");
4226
+ });
4227
+
4228
+ it("returns unavailable when Xvfb binary is missing", async () => {
4229
+ launcher.getSession.mockReturnValue({
4230
+ sessionId: "s1",
4231
+ state: "running",
4232
+ cwd: "/repo",
4233
+ containerId: "cid-1",
4234
+ });
4235
+ vi.spyOn(containerManager, "getContainer").mockReturnValue({
4236
+ containerId: "cid-1",
4237
+ name: "companion-s1",
4238
+ image: "the-companion:latest",
4239
+ portMappings: [{ containerPort: 6080, hostPort: 49200 }],
4240
+ hostCwd: "/repo",
4241
+ containerCwd: "/workspace",
4242
+ state: "running",
4243
+ });
4244
+ vi.spyOn(containerManager, "isContainerAlive").mockReturnValue("running");
4245
+ // Xvfb not found, websockify found
4246
+ vi.spyOn(containerManager, "hasBinaryInContainer").mockImplementation(
4247
+ (_cid: string, bin: string) => bin !== "Xvfb",
4248
+ );
4249
+
4250
+ const res = await app.request("/api/sessions/s1/browser/start", { method: "POST" });
4251
+
4252
+ expect(res.status).toBe(200);
4253
+ const json = await res.json();
4254
+ expect(json).toMatchObject({
4255
+ available: false,
4256
+ mode: "container",
4257
+ });
4258
+ expect(json.message).toContain("Xvfb and noVNC");
4259
+ });
4260
+
4261
+ it("starts display stack and returns proxied URL for container session", async () => {
4262
+ launcher.getSession.mockReturnValue({
4263
+ sessionId: "s1",
4264
+ state: "running",
4265
+ cwd: "/repo",
4266
+ containerId: "cid-1",
4267
+ });
4268
+ vi.spyOn(containerManager, "getContainer").mockReturnValue({
4269
+ containerId: "cid-1",
4270
+ name: "companion-s1",
4271
+ image: "the-companion:latest",
4272
+ portMappings: [{ containerPort: 6080, hostPort: 49200 }],
4273
+ hostCwd: "/repo",
4274
+ containerCwd: "/workspace",
4275
+ state: "running",
4276
+ });
4277
+ vi.spyOn(containerManager, "hasBinaryInContainer").mockReturnValue(true);
4278
+ vi.spyOn(containerManager, "isContainerAlive").mockReturnValue("running");
4279
+ const execSpy = vi.spyOn(containerManager, "execInContainerAsync").mockResolvedValue({ exitCode: 0, output: "" });
4280
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok", { status: 200 }));
4281
+
4282
+ const res = await app.request("/api/sessions/s1/browser/start", { method: "POST" });
4283
+
4284
+ expect(res.status).toBe(200);
4285
+ const json = await res.json();
4286
+ expect(json).toMatchObject({
4287
+ available: true,
4288
+ mode: "container",
4289
+ });
4290
+ // URL should be a proxied path through the companion server
4291
+ expect(json.url).toContain("/api/sessions/s1/browser/proxy/vnc.html");
4292
+ expect(json.url).toContain("autoconnect=true");
4293
+ expect(json.url).toContain("path=ws/novnc/s1");
4294
+ // Should have called execInContainerAsync for the display stack and Chrome
4295
+ expect(execSpy).toHaveBeenCalledTimes(2);
4296
+ fetchSpy.mockRestore();
4297
+ });
4298
+
4299
+ it("returns unavailable when noVNC polling times out", { timeout: 25_000 }, async () => {
4300
+ launcher.getSession.mockReturnValue({
4301
+ sessionId: "s1",
4302
+ state: "running",
4303
+ cwd: "/repo",
4304
+ containerId: "cid-1",
4305
+ });
4306
+ vi.spyOn(containerManager, "getContainer").mockReturnValue({
4307
+ containerId: "cid-1",
4308
+ name: "companion-s1",
4309
+ image: "the-companion:latest",
4310
+ portMappings: [{ containerPort: 6080, hostPort: 49200 }],
4311
+ hostCwd: "/repo",
4312
+ containerCwd: "/workspace",
4313
+ state: "running",
4314
+ });
4315
+ vi.spyOn(containerManager, "hasBinaryInContainer").mockReturnValue(true);
4316
+ vi.spyOn(containerManager, "isContainerAlive").mockReturnValue("running");
4317
+ vi.spyOn(containerManager, "execInContainerAsync").mockResolvedValue({ exitCode: 0, output: "" });
4318
+ // Simulate noVNC never becoming ready — all fetches throw
4319
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("connection refused"));
4320
+
4321
+ const res = await app.request("/api/sessions/s1/browser/start", { method: "POST" });
4322
+
4323
+ expect(res.status).toBe(200);
4324
+ const json = await res.json();
4325
+ expect(json).toMatchObject({
4326
+ available: false,
4327
+ mode: "container",
4328
+ });
4329
+ expect(json.message).toContain("timed out");
4330
+ fetchSpy.mockRestore();
4331
+ });
4332
+
4333
+ it("rejects file:// URL scheme in browser/start", async () => {
4334
+ launcher.getSession.mockReturnValue({
4335
+ sessionId: "s1",
4336
+ state: "running",
4337
+ cwd: "/repo",
4338
+ containerId: "cid-1",
4339
+ });
4340
+ vi.spyOn(containerManager, "getContainer").mockReturnValue({
4341
+ containerId: "cid-1",
4342
+ name: "companion-s1",
4343
+ image: "the-companion:latest",
4344
+ portMappings: [{ containerPort: 6080, hostPort: 49200 }],
4345
+ hostCwd: "/repo",
4346
+ containerCwd: "/workspace",
4347
+ state: "running",
4348
+ });
4349
+ vi.spyOn(containerManager, "hasBinaryInContainer").mockReturnValue(true);
4350
+ vi.spyOn(containerManager, "isContainerAlive").mockReturnValue("running");
4351
+ vi.spyOn(containerManager, "execInContainerAsync").mockResolvedValue({ exitCode: 0, output: "" });
4352
+
4353
+ const res = await app.request("/api/sessions/s1/browser/start", {
4354
+ method: "POST",
4355
+ headers: { "Content-Type": "application/json" },
4356
+ body: JSON.stringify({ url: "file:///etc/passwd" }),
4357
+ });
4358
+
4359
+ expect(res.status).toBe(200);
4360
+ const json = await res.json();
4361
+ expect(json).toMatchObject({ available: false });
4362
+ expect(json.message).toContain("http://");
4363
+ });
4364
+ });
4365
+
4366
+ describe("POST /api/sessions/:id/browser/navigate", () => {
4367
+ it("returns 404 when session not found", async () => {
4368
+ launcher.getSession.mockReturnValue(undefined);
4369
+
4370
+ const res = await app.request("/api/sessions/s1/browser/navigate", {
4371
+ method: "POST",
4372
+ headers: { "Content-Type": "application/json" },
4373
+ body: JSON.stringify({ url: "http://localhost:3000" }),
4374
+ });
4375
+
4376
+ expect(res.status).toBe(404);
4377
+ });
4378
+
4379
+ it("returns 400 for non-container session", async () => {
4380
+ launcher.getSession.mockReturnValue({
4381
+ sessionId: "s1",
4382
+ state: "running",
4383
+ cwd: "/repo",
4384
+ });
4385
+
4386
+ const res = await app.request("/api/sessions/s1/browser/navigate", {
4387
+ method: "POST",
4388
+ headers: { "Content-Type": "application/json" },
4389
+ body: JSON.stringify({ url: "http://localhost:3000" }),
4390
+ });
4391
+
4392
+ expect(res.status).toBe(400);
4393
+ });
4394
+
4395
+ it("rejects file:// URL scheme", async () => {
4396
+ launcher.getSession.mockReturnValue({
4397
+ sessionId: "s1",
4398
+ state: "running",
4399
+ cwd: "/repo",
4400
+ containerId: "cid-1",
4401
+ });
4402
+
4403
+ const res = await app.request("/api/sessions/s1/browser/navigate", {
4404
+ method: "POST",
4405
+ headers: { "Content-Type": "application/json" },
4406
+ body: JSON.stringify({ url: "file:///etc/passwd" }),
4407
+ });
4408
+
4409
+ expect(res.status).toBe(400);
4410
+ const json = await res.json();
4411
+ expect(json.error).toContain("http://");
4412
+ });
4413
+
4414
+ it("navigates Chrome to the given URL", async () => {
4415
+ launcher.getSession.mockReturnValue({
4416
+ sessionId: "s1",
4417
+ state: "running",
4418
+ cwd: "/repo",
4419
+ containerId: "cid-1",
4420
+ });
4421
+ vi.spyOn(containerManager, "getContainer").mockReturnValue({
4422
+ containerId: "cid-1",
4423
+ name: "companion-s1",
4424
+ image: "the-companion:latest",
4425
+ portMappings: [],
4426
+ hostCwd: "/repo",
4427
+ containerCwd: "/workspace",
4428
+ state: "running",
4429
+ });
4430
+ const execSpy = vi.spyOn(containerManager, "execInContainerAsync").mockResolvedValue({ exitCode: 0, output: "" });
4431
+
4432
+ const res = await app.request("/api/sessions/s1/browser/navigate", {
4433
+ method: "POST",
4434
+ headers: { "Content-Type": "application/json" },
4435
+ body: JSON.stringify({ url: "http://localhost:3000" }),
4436
+ });
4437
+
4438
+ expect(res.status).toBe(200);
4439
+ const json = await res.json();
4440
+ expect(json).toMatchObject({ ok: true, url: "http://localhost:3000" });
4441
+ expect(execSpy).toHaveBeenCalledWith(
4442
+ "cid-1",
4443
+ expect.arrayContaining(["sh", "-c"]),
4444
+ { timeout: 10_000 },
4445
+ );
4446
+ });
4447
+ });
4448
+
4449
+ describe("GET /api/sessions/:id/browser/proxy/*", () => {
4450
+ it("returns 404 when session not found", async () => {
4451
+ launcher.getSession.mockReturnValue(undefined);
4452
+
4453
+ const res = await app.request("/api/sessions/s1/browser/proxy/vnc.html");
4454
+
4455
+ expect(res.status).toBe(404);
4456
+ });
4457
+
4458
+ it("returns 400 for non-container session", async () => {
4459
+ launcher.getSession.mockReturnValue({
4460
+ sessionId: "s1",
4461
+ state: "running",
4462
+ cwd: "/repo",
4463
+ });
4464
+
4465
+ const res = await app.request("/api/sessions/s1/browser/proxy/vnc.html");
4466
+
4467
+ expect(res.status).toBe(400);
4468
+ });
4469
+
4470
+ it("proxies request to container noVNC server", async () => {
4471
+ launcher.getSession.mockReturnValue({
4472
+ sessionId: "s1",
4473
+ state: "running",
4474
+ cwd: "/repo",
4475
+ containerId: "cid-1",
4476
+ });
4477
+ vi.spyOn(containerManager, "getContainer").mockReturnValue({
4478
+ containerId: "cid-1",
4479
+ name: "companion-s1",
4480
+ image: "the-companion:latest",
4481
+ portMappings: [{ containerPort: 6080, hostPort: 49200 }],
4482
+ hostCwd: "/repo",
4483
+ containerCwd: "/workspace",
4484
+ state: "running",
4485
+ });
4486
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
4487
+ new Response("<html>noVNC</html>", {
4488
+ status: 200,
4489
+ headers: { "Content-Type": "text/html" },
4490
+ }),
4491
+ );
4492
+
4493
+ const res = await app.request("/api/sessions/s1/browser/proxy/vnc.html?autoconnect=true");
4494
+
4495
+ expect(res.status).toBe(200);
4496
+ const body = await res.text();
4497
+ expect(body).toBe("<html>noVNC</html>");
4498
+ // fetch should have been called with the container's mapped port
4499
+ expect(fetchSpy).toHaveBeenCalledWith(
4500
+ expect.stringContaining("http://127.0.0.1:49200/vnc.html"),
4501
+ );
4502
+ fetchSpy.mockRestore();
4503
+ });
4504
+ });
4505
+
4506
+ describe("GET /api/sessions/:id/browser/host-proxy/:port/*", () => {
4507
+ it("returns 404 when session not found", async () => {
4508
+ launcher.getSession.mockReturnValue(undefined);
4509
+
4510
+ const res = await app.request("/api/sessions/s1/browser/host-proxy/3000/index.html");
4511
+
4512
+ expect(res.status).toBe(404);
4513
+ });
4514
+
4515
+ it("returns 400 for invalid port", async () => {
4516
+ launcher.getSession.mockReturnValue({
4517
+ sessionId: "s1",
4518
+ state: "running",
4519
+ cwd: "/repo",
4520
+ });
4521
+
4522
+ const res = await app.request("/api/sessions/s1/browser/host-proxy/99999/index.html");
4523
+
4524
+ expect(res.status).toBe(400);
4525
+ const json = await res.json();
4526
+ expect(json.error).toContain("Invalid port");
4527
+ });
4528
+
4529
+ it("returns 400 for non-numeric port", async () => {
4530
+ launcher.getSession.mockReturnValue({
4531
+ sessionId: "s1",
4532
+ state: "running",
4533
+ cwd: "/repo",
4534
+ });
4535
+
4536
+ const res = await app.request("/api/sessions/s1/browser/host-proxy/abc/index.html");
4537
+
4538
+ expect(res.status).toBe(400);
4539
+ const json = await res.json();
4540
+ expect(json.error).toContain("Invalid port");
4541
+ });
4542
+
4543
+ // Security: Hono's router resolves literal ".." and "%2e%2e" before matching,
4544
+ // returning 404 automatically. Our handler adds a defense-in-depth check for
4545
+ // real HTTP servers where encoded traversal may bypass router normalization.
4546
+ it("Hono blocks path traversal at router level (returns 404 not route match)", async () => {
4547
+ launcher.getSession.mockReturnValue({
4548
+ sessionId: "s1",
4549
+ state: "running",
4550
+ cwd: "/repo",
4551
+ });
4552
+
4553
+ // Both literal and encoded ".." are resolved by Hono's router before matching
4554
+ const res = await app.request("/api/sessions/s1/browser/host-proxy/3000/%2e%2e/%2e%2e/etc/passwd");
4555
+ expect(res.status).toBe(404);
4556
+ });
4557
+
4558
+ // Security: block proxying to the companion server itself (would bypass remote auth)
4559
+ it("rejects proxying to the companion server port", async () => {
4560
+ launcher.getSession.mockReturnValue({
4561
+ sessionId: "s1",
4562
+ state: "running",
4563
+ cwd: "/repo",
4564
+ });
4565
+
4566
+ // Default dev port is 3457
4567
+ const res = await app.request("/api/sessions/s1/browser/host-proxy/3457/api/sessions");
4568
+
4569
+ expect(res.status).toBe(400);
4570
+ const json = await res.json();
4571
+ expect(json.error).toContain("Port not allowed");
4572
+ });
4573
+
4574
+ it("blocks well-known sensitive service ports", async () => {
4575
+ // Sensitive ports (databases, caches, mail) should be blocked to limit SSRF
4576
+ launcher.getSession.mockReturnValue({
4577
+ sessionId: "s1",
4578
+ state: "running",
4579
+ cwd: "/repo",
4580
+ });
4581
+
4582
+ for (const blockedPort of [5432, 3306, 6379, 27017]) {
4583
+ const res = await app.request(`/api/sessions/s1/browser/host-proxy/${blockedPort}/`);
4584
+ expect(res.status).toBe(400);
4585
+ const json = await res.json();
4586
+ expect(json.error).toContain("Port not allowed");
4587
+ }
4588
+ });
4589
+
4590
+ it("proxies request to localhost on the given port", async () => {
4591
+ launcher.getSession.mockReturnValue({
4592
+ sessionId: "s1",
4593
+ state: "running",
4594
+ cwd: "/repo",
4595
+ });
4596
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
4597
+ new Response("<html>App</html>", {
4598
+ status: 200,
4599
+ headers: { "Content-Type": "text/html" },
4600
+ }),
4601
+ );
4602
+
4603
+ const res = await app.request("/api/sessions/s1/browser/host-proxy/3000/index.html");
4604
+
4605
+ expect(res.status).toBe(200);
4606
+ const body = await res.text();
4607
+ expect(body).toBe("<html>App</html>");
4608
+ // fetch should target 127.0.0.1 with the specified port and sub-path
4609
+ expect(fetchSpy).toHaveBeenCalledWith(
4610
+ "http://127.0.0.1:3000/index.html",
4611
+ expect.objectContaining({ redirect: "follow" }),
4612
+ );
4613
+ fetchSpy.mockRestore();
4614
+ });
4615
+
4616
+ it("forwards query string to upstream", async () => {
4617
+ launcher.getSession.mockReturnValue({
4618
+ sessionId: "s1",
4619
+ state: "running",
4620
+ cwd: "/repo",
4621
+ });
4622
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
4623
+ new Response("ok", { status: 200 }),
4624
+ );
4625
+
4626
+ const res = await app.request("/api/sessions/s1/browser/host-proxy/5173/assets/main.js?v=123");
4627
+
4628
+ expect(res.status).toBe(200);
4629
+ expect(fetchSpy).toHaveBeenCalledWith(
4630
+ "http://127.0.0.1:5173/assets/main.js?v=123",
4631
+ expect.objectContaining({ redirect: "follow" }),
4632
+ );
4633
+ fetchSpy.mockRestore();
4634
+ });
4635
+
4636
+ // Error message should be generic to avoid leaking internal network info
4637
+ it("returns generic 502 when upstream is unreachable", async () => {
4638
+ launcher.getSession.mockReturnValue({
4639
+ sessionId: "s1",
4640
+ state: "running",
4641
+ cwd: "/repo",
4642
+ });
4643
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockRejectedValue(
4644
+ new Error("Connection refused"),
4645
+ );
4646
+
4647
+ const res = await app.request("/api/sessions/s1/browser/host-proxy/9999/");
4648
+
4649
+ expect(res.status).toBe(502);
4650
+ const json = await res.json();
4651
+ // Should NOT leak the raw error message (e.g. "Connection refused 127.0.0.1:9999")
4652
+ expect(json.error).toBe("Proxy failed: upstream unreachable");
4653
+ fetchSpy.mockRestore();
4654
+ });
4655
+ });