@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.
- package/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- 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
|
+
});
|