@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,637 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ─── Mock usage-limits ─────────────────────────────────────────────────────
|
|
4
|
+
vi.mock("../usage-limits.js", () => ({
|
|
5
|
+
getUsageLimits: vi.fn(async () => ({
|
|
6
|
+
five_hour: null,
|
|
7
|
+
seven_day: null,
|
|
8
|
+
extra_usage: null,
|
|
9
|
+
})),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
// ─── Mock update-checker ───────────────────────────────────────────────────
|
|
13
|
+
vi.mock("../update-checker.js", () => ({
|
|
14
|
+
getUpdateState: vi.fn(() => ({
|
|
15
|
+
currentVersion: "1.0.0",
|
|
16
|
+
latestVersion: null,
|
|
17
|
+
lastChecked: 0,
|
|
18
|
+
isServiceMode: false,
|
|
19
|
+
checking: false,
|
|
20
|
+
updateInProgress: false,
|
|
21
|
+
channel: "stable",
|
|
22
|
+
})),
|
|
23
|
+
checkForUpdate: vi.fn(async () => {}),
|
|
24
|
+
isUpdateAvailable: vi.fn(() => false),
|
|
25
|
+
setUpdateInProgress: vi.fn(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// ─── Mock service ──────────────────────────────────────────────────────────
|
|
29
|
+
vi.mock("../service.js", () => ({
|
|
30
|
+
refreshServiceDefinition: vi.fn(),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
import { Hono } from "hono";
|
|
34
|
+
import { getUsageLimits } from "../usage-limits.js";
|
|
35
|
+
import {
|
|
36
|
+
getUpdateState,
|
|
37
|
+
checkForUpdate,
|
|
38
|
+
isUpdateAvailable,
|
|
39
|
+
setUpdateInProgress,
|
|
40
|
+
} from "../update-checker.js";
|
|
41
|
+
import { registerSystemRoutes } from "./system-routes.js";
|
|
42
|
+
|
|
43
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/** Build a mock CliLauncher with vi.fn() stubs for the methods used by system routes. */
|
|
46
|
+
function createMockLauncher() {
|
|
47
|
+
return {
|
|
48
|
+
getSession: vi.fn(() => undefined as any),
|
|
49
|
+
isAlive: vi.fn(() => false),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Build a mock WsBridge with vi.fn() stubs for the methods used by system routes. */
|
|
54
|
+
function createMockWsBridge() {
|
|
55
|
+
return {
|
|
56
|
+
getSession: vi.fn(() => undefined as any),
|
|
57
|
+
getCodexRateLimits: vi.fn(() => null),
|
|
58
|
+
injectUserMessage: vi.fn(),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Build a mock TerminalManager with vi.fn() stubs for the methods used by system routes. */
|
|
63
|
+
function createMockTerminalManager() {
|
|
64
|
+
return {
|
|
65
|
+
getInfo: vi.fn(() => null as { id: string; cwd: string } | null),
|
|
66
|
+
spawn: vi.fn(() => "terminal-123"),
|
|
67
|
+
kill: vi.fn(),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Test setup ────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
let app: Hono;
|
|
74
|
+
let launcher: ReturnType<typeof createMockLauncher>;
|
|
75
|
+
let wsBridge: ReturnType<typeof createMockWsBridge>;
|
|
76
|
+
let terminalManager: ReturnType<typeof createMockTerminalManager>;
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
vi.clearAllMocks();
|
|
80
|
+
|
|
81
|
+
launcher = createMockLauncher();
|
|
82
|
+
wsBridge = createMockWsBridge();
|
|
83
|
+
terminalManager = createMockTerminalManager();
|
|
84
|
+
|
|
85
|
+
app = new Hono();
|
|
86
|
+
const api = new Hono();
|
|
87
|
+
registerSystemRoutes(api, {
|
|
88
|
+
launcher: launcher as any,
|
|
89
|
+
wsBridge: wsBridge as any,
|
|
90
|
+
terminalManager: terminalManager as any,
|
|
91
|
+
updateCheckStaleMs: 60_000,
|
|
92
|
+
});
|
|
93
|
+
app.route("/api", api);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
97
|
+
// GET /api/usage-limits
|
|
98
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
99
|
+
|
|
100
|
+
describe("GET /api/usage-limits", () => {
|
|
101
|
+
it("returns usage limits from the global getter", async () => {
|
|
102
|
+
const limits = {
|
|
103
|
+
five_hour: { utilization: 0.5, resets_at: "2026-01-01T00:00:00Z" },
|
|
104
|
+
seven_day: null,
|
|
105
|
+
extra_usage: null,
|
|
106
|
+
};
|
|
107
|
+
vi.mocked(getUsageLimits).mockResolvedValue(limits as any);
|
|
108
|
+
|
|
109
|
+
const res = await app.request("/api/usage-limits");
|
|
110
|
+
|
|
111
|
+
expect(res.status).toBe(200);
|
|
112
|
+
const json = await res.json();
|
|
113
|
+
expect(json.five_hour.utilization).toBe(0.5);
|
|
114
|
+
expect(json.seven_day).toBeNull();
|
|
115
|
+
expect(getUsageLimits).toHaveBeenCalled();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
120
|
+
// GET /api/sessions/:id/usage-limits
|
|
121
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
122
|
+
|
|
123
|
+
describe("GET /api/sessions/:id/usage-limits", () => {
|
|
124
|
+
it("returns codex rate limits when the session is a codex backend", async () => {
|
|
125
|
+
// When the session's backendType is "codex", we should return mapped codex limits
|
|
126
|
+
wsBridge.getSession.mockReturnValue({ backendType: "codex" } as any);
|
|
127
|
+
wsBridge.getCodexRateLimits.mockReturnValue({
|
|
128
|
+
primary: { usedPercent: 0.42, windowDurationMins: 300, resetsAt: 1700000000 },
|
|
129
|
+
secondary: null,
|
|
130
|
+
} as any);
|
|
131
|
+
|
|
132
|
+
const res = await app.request("/api/sessions/codex-sess-1/usage-limits");
|
|
133
|
+
|
|
134
|
+
expect(res.status).toBe(200);
|
|
135
|
+
const json = await res.json();
|
|
136
|
+
// Primary limit should be mapped to five_hour
|
|
137
|
+
expect(json.five_hour).not.toBeNull();
|
|
138
|
+
expect(json.five_hour.utilization).toBe(0.42);
|
|
139
|
+
// Secondary was null, so seven_day should be null
|
|
140
|
+
expect(json.seven_day).toBeNull();
|
|
141
|
+
expect(json.extra_usage).toBeNull();
|
|
142
|
+
// Should NOT have called getUsageLimits (we used codex-specific path)
|
|
143
|
+
expect(getUsageLimits).not.toHaveBeenCalled();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("returns empty limits when codex session has no rate limit data", async () => {
|
|
147
|
+
wsBridge.getSession.mockReturnValue({ backendType: "codex" } as any);
|
|
148
|
+
wsBridge.getCodexRateLimits.mockReturnValue(null);
|
|
149
|
+
|
|
150
|
+
const res = await app.request("/api/sessions/codex-sess-2/usage-limits");
|
|
151
|
+
|
|
152
|
+
expect(res.status).toBe(200);
|
|
153
|
+
const json = await res.json();
|
|
154
|
+
expect(json).toEqual({ five_hour: null, seven_day: null, extra_usage: null });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// When codex rate limits have timestamps in epoch milliseconds (>1e12),
|
|
158
|
+
// they should pass through without conversion.
|
|
159
|
+
it("passes through millisecond timestamps from codex rate limits", async () => {
|
|
160
|
+
wsBridge.getSession.mockReturnValue({ backendType: "codex" } as any);
|
|
161
|
+
const msTimestamp = 1700000000000; // already in ms
|
|
162
|
+
wsBridge.getCodexRateLimits.mockReturnValue({
|
|
163
|
+
primary: { usedPercent: 0.8, windowDurationMins: 300, resetsAt: msTimestamp },
|
|
164
|
+
secondary: { usedPercent: 0.3, windowDurationMins: 10080, resetsAt: msTimestamp },
|
|
165
|
+
} as any);
|
|
166
|
+
|
|
167
|
+
const res = await app.request("/api/sessions/codex-ms/usage-limits");
|
|
168
|
+
|
|
169
|
+
expect(res.status).toBe(200);
|
|
170
|
+
const json = await res.json();
|
|
171
|
+
expect(json.five_hour.utilization).toBe(0.8);
|
|
172
|
+
expect(json.five_hour.resets_at).toBe(new Date(msTimestamp).toISOString());
|
|
173
|
+
expect(json.seven_day.utilization).toBe(0.3);
|
|
174
|
+
expect(json.seven_day.resets_at).toBe(new Date(msTimestamp).toISOString());
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("falls back to global usage limits for non-codex sessions", async () => {
|
|
178
|
+
// A claude-type session should use the global getUsageLimits
|
|
179
|
+
wsBridge.getSession.mockReturnValue({ backendType: "claude" } as any);
|
|
180
|
+
vi.mocked(getUsageLimits).mockResolvedValue({
|
|
181
|
+
five_hour: { utilization: 0.1, resets_at: null },
|
|
182
|
+
seven_day: null,
|
|
183
|
+
extra_usage: null,
|
|
184
|
+
} as any);
|
|
185
|
+
|
|
186
|
+
const res = await app.request("/api/sessions/claude-sess-1/usage-limits");
|
|
187
|
+
|
|
188
|
+
expect(res.status).toBe(200);
|
|
189
|
+
const json = await res.json();
|
|
190
|
+
expect(json.five_hour.utilization).toBe(0.1);
|
|
191
|
+
expect(getUsageLimits).toHaveBeenCalled();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("falls back to global usage limits when session is not found", async () => {
|
|
195
|
+
// When wsBridge.getSession returns undefined, should still return global limits
|
|
196
|
+
wsBridge.getSession.mockReturnValue(undefined);
|
|
197
|
+
vi.mocked(getUsageLimits).mockResolvedValue({
|
|
198
|
+
five_hour: null,
|
|
199
|
+
seven_day: null,
|
|
200
|
+
extra_usage: null,
|
|
201
|
+
} as any);
|
|
202
|
+
|
|
203
|
+
const res = await app.request("/api/sessions/unknown/usage-limits");
|
|
204
|
+
|
|
205
|
+
expect(res.status).toBe(200);
|
|
206
|
+
expect(getUsageLimits).toHaveBeenCalled();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
211
|
+
// GET /api/update-check
|
|
212
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
213
|
+
|
|
214
|
+
describe("GET /api/update-check", () => {
|
|
215
|
+
it("calls checkForUpdate when lastChecked is 0 (stale)", async () => {
|
|
216
|
+
// lastChecked=0 means never checked, so it should trigger a refresh
|
|
217
|
+
vi.mocked(getUpdateState).mockReturnValue({
|
|
218
|
+
currentVersion: "1.0.0",
|
|
219
|
+
latestVersion: null,
|
|
220
|
+
lastChecked: 0,
|
|
221
|
+
isServiceMode: false,
|
|
222
|
+
checking: false,
|
|
223
|
+
updateInProgress: false,
|
|
224
|
+
channel: "stable",
|
|
225
|
+
});
|
|
226
|
+
vi.mocked(isUpdateAvailable).mockReturnValue(false);
|
|
227
|
+
|
|
228
|
+
const res = await app.request("/api/update-check");
|
|
229
|
+
|
|
230
|
+
expect(res.status).toBe(200);
|
|
231
|
+
expect(checkForUpdate).toHaveBeenCalled();
|
|
232
|
+
const json = await res.json();
|
|
233
|
+
expect(json.currentVersion).toBe("1.0.0");
|
|
234
|
+
expect(json.updateAvailable).toBe(false);
|
|
235
|
+
expect(json.channel).toBe("stable");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("does NOT call checkForUpdate when lastChecked is recent (not stale)", async () => {
|
|
239
|
+
// Set lastChecked to "now" so it is within the 60s stale window
|
|
240
|
+
vi.mocked(getUpdateState).mockReturnValue({
|
|
241
|
+
currentVersion: "1.0.0",
|
|
242
|
+
latestVersion: "1.0.0",
|
|
243
|
+
lastChecked: Date.now(),
|
|
244
|
+
isServiceMode: false,
|
|
245
|
+
checking: false,
|
|
246
|
+
updateInProgress: false,
|
|
247
|
+
channel: "stable",
|
|
248
|
+
});
|
|
249
|
+
vi.mocked(isUpdateAvailable).mockReturnValue(false);
|
|
250
|
+
|
|
251
|
+
const res = await app.request("/api/update-check");
|
|
252
|
+
|
|
253
|
+
expect(res.status).toBe(200);
|
|
254
|
+
expect(checkForUpdate).not.toHaveBeenCalled();
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
259
|
+
// POST /api/update-check
|
|
260
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
261
|
+
|
|
262
|
+
describe("POST /api/update-check", () => {
|
|
263
|
+
it("always calls checkForUpdate regardless of staleness", async () => {
|
|
264
|
+
vi.mocked(getUpdateState).mockReturnValue({
|
|
265
|
+
currentVersion: "1.0.0",
|
|
266
|
+
latestVersion: "2.0.0",
|
|
267
|
+
lastChecked: Date.now(),
|
|
268
|
+
isServiceMode: true,
|
|
269
|
+
checking: false,
|
|
270
|
+
updateInProgress: false,
|
|
271
|
+
channel: "stable",
|
|
272
|
+
});
|
|
273
|
+
vi.mocked(isUpdateAvailable).mockReturnValue(true);
|
|
274
|
+
|
|
275
|
+
const res = await app.request("/api/update-check", { method: "POST" });
|
|
276
|
+
|
|
277
|
+
expect(res.status).toBe(200);
|
|
278
|
+
expect(checkForUpdate).toHaveBeenCalled();
|
|
279
|
+
const json = await res.json();
|
|
280
|
+
expect(json.updateAvailable).toBe(true);
|
|
281
|
+
expect(json.isServiceMode).toBe(true);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
286
|
+
// POST /api/update
|
|
287
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
288
|
+
|
|
289
|
+
describe("POST /api/update", () => {
|
|
290
|
+
it("returns 400 when not running in service mode", async () => {
|
|
291
|
+
vi.mocked(getUpdateState).mockReturnValue({
|
|
292
|
+
currentVersion: "1.0.0",
|
|
293
|
+
latestVersion: "2.0.0",
|
|
294
|
+
lastChecked: Date.now(),
|
|
295
|
+
isServiceMode: false,
|
|
296
|
+
checking: false,
|
|
297
|
+
updateInProgress: false,
|
|
298
|
+
channel: "stable",
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const res = await app.request("/api/update", { method: "POST" });
|
|
302
|
+
|
|
303
|
+
expect(res.status).toBe(400);
|
|
304
|
+
const json = await res.json();
|
|
305
|
+
expect(json.error).toMatch(/service mode/i);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("returns 400 when no update is available", async () => {
|
|
309
|
+
vi.mocked(getUpdateState).mockReturnValue({
|
|
310
|
+
currentVersion: "1.0.0",
|
|
311
|
+
latestVersion: "1.0.0",
|
|
312
|
+
lastChecked: Date.now(),
|
|
313
|
+
isServiceMode: true,
|
|
314
|
+
checking: false,
|
|
315
|
+
updateInProgress: false,
|
|
316
|
+
channel: "stable",
|
|
317
|
+
});
|
|
318
|
+
vi.mocked(isUpdateAvailable).mockReturnValue(false);
|
|
319
|
+
|
|
320
|
+
const res = await app.request("/api/update", { method: "POST" });
|
|
321
|
+
|
|
322
|
+
expect(res.status).toBe(400);
|
|
323
|
+
const json = await res.json();
|
|
324
|
+
expect(json.error).toMatch(/no update/i);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("returns 409 when an update is already in progress", async () => {
|
|
328
|
+
vi.mocked(getUpdateState).mockReturnValue({
|
|
329
|
+
currentVersion: "1.0.0",
|
|
330
|
+
latestVersion: "2.0.0",
|
|
331
|
+
lastChecked: Date.now(),
|
|
332
|
+
isServiceMode: true,
|
|
333
|
+
checking: false,
|
|
334
|
+
updateInProgress: true,
|
|
335
|
+
channel: "stable",
|
|
336
|
+
});
|
|
337
|
+
vi.mocked(isUpdateAvailable).mockReturnValue(true);
|
|
338
|
+
|
|
339
|
+
const res = await app.request("/api/update", { method: "POST" });
|
|
340
|
+
|
|
341
|
+
expect(res.status).toBe(409);
|
|
342
|
+
const json = await res.json();
|
|
343
|
+
expect(json.error).toMatch(/already in progress/i);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("starts the update when all preconditions are met", async () => {
|
|
347
|
+
vi.mocked(getUpdateState).mockReturnValue({
|
|
348
|
+
currentVersion: "1.0.0",
|
|
349
|
+
latestVersion: "2.0.0",
|
|
350
|
+
lastChecked: Date.now(),
|
|
351
|
+
isServiceMode: true,
|
|
352
|
+
checking: false,
|
|
353
|
+
updateInProgress: false,
|
|
354
|
+
channel: "stable",
|
|
355
|
+
});
|
|
356
|
+
vi.mocked(isUpdateAvailable).mockReturnValue(true);
|
|
357
|
+
|
|
358
|
+
const res = await app.request("/api/update", { method: "POST" });
|
|
359
|
+
|
|
360
|
+
expect(res.status).toBe(200);
|
|
361
|
+
const json = await res.json();
|
|
362
|
+
expect(json.ok).toBe(true);
|
|
363
|
+
expect(json.message).toMatch(/restart/i);
|
|
364
|
+
expect(setUpdateInProgress).toHaveBeenCalledWith(true);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// Exercises the async setTimeout callback inside the update handler.
|
|
368
|
+
// Mocks Bun.spawn to simulate a successful install + restart.
|
|
369
|
+
it("runs the install and restart flow inside the deferred callback", async () => {
|
|
370
|
+
vi.useFakeTimers();
|
|
371
|
+
|
|
372
|
+
vi.mocked(getUpdateState).mockReturnValue({
|
|
373
|
+
currentVersion: "1.0.0",
|
|
374
|
+
latestVersion: "2.0.0",
|
|
375
|
+
lastChecked: Date.now(),
|
|
376
|
+
isServiceMode: true,
|
|
377
|
+
checking: false,
|
|
378
|
+
updateInProgress: false,
|
|
379
|
+
channel: "stable",
|
|
380
|
+
});
|
|
381
|
+
vi.mocked(isUpdateAvailable).mockReturnValue(true);
|
|
382
|
+
|
|
383
|
+
// Mock Bun.spawn for the install command
|
|
384
|
+
const mockSpawn = vi.fn()
|
|
385
|
+
.mockReturnValueOnce({
|
|
386
|
+
exited: Promise.resolve(0),
|
|
387
|
+
stdout: new ReadableStream(),
|
|
388
|
+
stderr: new ReadableStream(),
|
|
389
|
+
})
|
|
390
|
+
// Second call is the restart command
|
|
391
|
+
.mockReturnValueOnce({
|
|
392
|
+
exited: Promise.resolve(0),
|
|
393
|
+
stdout: new ReadableStream(),
|
|
394
|
+
stderr: new ReadableStream(),
|
|
395
|
+
});
|
|
396
|
+
// @ts-expect-error -- Bun global mock
|
|
397
|
+
globalThis.Bun = { spawn: mockSpawn };
|
|
398
|
+
|
|
399
|
+
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
|
400
|
+
|
|
401
|
+
const res = await app.request("/api/update", { method: "POST" });
|
|
402
|
+
expect(res.status).toBe(200);
|
|
403
|
+
|
|
404
|
+
// Advance past the 100ms setTimeout that starts the install
|
|
405
|
+
await vi.advanceTimersByTimeAsync(150);
|
|
406
|
+
|
|
407
|
+
// The install spawn should have been called
|
|
408
|
+
expect(mockSpawn).toHaveBeenCalledWith(
|
|
409
|
+
["bun", "install", "-g", "@hellcoder/companion@2.0.0"],
|
|
410
|
+
expect.anything(),
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
// Advance past the 500ms exit timeout
|
|
414
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
415
|
+
|
|
416
|
+
vi.useRealTimers();
|
|
417
|
+
exitSpy.mockRestore();
|
|
418
|
+
// @ts-expect-error -- cleanup Bun global mock
|
|
419
|
+
delete globalThis.Bun;
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// When the install command fails, setUpdateInProgress should be reset.
|
|
423
|
+
it("resets updateInProgress when install fails", async () => {
|
|
424
|
+
vi.useFakeTimers();
|
|
425
|
+
|
|
426
|
+
vi.mocked(getUpdateState).mockReturnValue({
|
|
427
|
+
currentVersion: "1.0.0",
|
|
428
|
+
latestVersion: "2.0.0",
|
|
429
|
+
lastChecked: Date.now(),
|
|
430
|
+
isServiceMode: true,
|
|
431
|
+
checking: false,
|
|
432
|
+
updateInProgress: false,
|
|
433
|
+
channel: "stable",
|
|
434
|
+
});
|
|
435
|
+
vi.mocked(isUpdateAvailable).mockReturnValue(true);
|
|
436
|
+
|
|
437
|
+
const stderrStream = new ReadableStream({
|
|
438
|
+
start(controller) {
|
|
439
|
+
controller.enqueue(new TextEncoder().encode("install error"));
|
|
440
|
+
controller.close();
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
const mockSpawn = vi.fn().mockReturnValueOnce({
|
|
444
|
+
exited: Promise.resolve(1),
|
|
445
|
+
stdout: new ReadableStream(),
|
|
446
|
+
stderr: stderrStream,
|
|
447
|
+
});
|
|
448
|
+
// @ts-expect-error -- Bun global mock
|
|
449
|
+
globalThis.Bun = { spawn: mockSpawn };
|
|
450
|
+
|
|
451
|
+
const res = await app.request("/api/update", { method: "POST" });
|
|
452
|
+
expect(res.status).toBe(200);
|
|
453
|
+
|
|
454
|
+
await vi.advanceTimersByTimeAsync(150);
|
|
455
|
+
|
|
456
|
+
// After failed install, setUpdateInProgress should be called with false
|
|
457
|
+
expect(setUpdateInProgress).toHaveBeenCalledWith(false);
|
|
458
|
+
|
|
459
|
+
vi.useRealTimers();
|
|
460
|
+
// @ts-expect-error -- cleanup Bun global mock
|
|
461
|
+
delete globalThis.Bun;
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
466
|
+
// GET /api/terminal
|
|
467
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
468
|
+
|
|
469
|
+
describe("GET /api/terminal", () => {
|
|
470
|
+
it("returns active: false when no terminal is running", async () => {
|
|
471
|
+
terminalManager.getInfo.mockReturnValue(null);
|
|
472
|
+
|
|
473
|
+
const res = await app.request("/api/terminal");
|
|
474
|
+
|
|
475
|
+
expect(res.status).toBe(200);
|
|
476
|
+
const json = await res.json();
|
|
477
|
+
expect(json.active).toBe(false);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("returns terminal info when a terminal is running", async () => {
|
|
481
|
+
terminalManager.getInfo.mockReturnValue({ id: "t-42", cwd: "/home/user" });
|
|
482
|
+
|
|
483
|
+
const res = await app.request("/api/terminal");
|
|
484
|
+
|
|
485
|
+
expect(res.status).toBe(200);
|
|
486
|
+
const json = await res.json();
|
|
487
|
+
expect(json.active).toBe(true);
|
|
488
|
+
expect(json.terminalId).toBe("t-42");
|
|
489
|
+
expect(json.cwd).toBe("/home/user");
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
494
|
+
// POST /api/terminal/spawn
|
|
495
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
496
|
+
|
|
497
|
+
describe("POST /api/terminal/spawn", () => {
|
|
498
|
+
it("spawns a terminal and returns its id", async () => {
|
|
499
|
+
terminalManager.spawn.mockReturnValue("new-terminal-id");
|
|
500
|
+
|
|
501
|
+
const res = await app.request("/api/terminal/spawn", {
|
|
502
|
+
method: "POST",
|
|
503
|
+
headers: { "Content-Type": "application/json" },
|
|
504
|
+
body: JSON.stringify({ cwd: "/workspace" }),
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
expect(res.status).toBe(200);
|
|
508
|
+
const json = await res.json();
|
|
509
|
+
expect(json.terminalId).toBe("new-terminal-id");
|
|
510
|
+
expect(terminalManager.spawn).toHaveBeenCalledWith(
|
|
511
|
+
"/workspace",
|
|
512
|
+
undefined,
|
|
513
|
+
undefined,
|
|
514
|
+
expect.objectContaining({}),
|
|
515
|
+
);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("returns 400 when cwd is missing", async () => {
|
|
519
|
+
const res = await app.request("/api/terminal/spawn", {
|
|
520
|
+
method: "POST",
|
|
521
|
+
headers: { "Content-Type": "application/json" },
|
|
522
|
+
body: JSON.stringify({}),
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
expect(res.status).toBe(400);
|
|
526
|
+
const json = await res.json();
|
|
527
|
+
expect(json.error).toMatch(/cwd/i);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
532
|
+
// POST /api/terminal/kill
|
|
533
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
534
|
+
|
|
535
|
+
describe("POST /api/terminal/kill", () => {
|
|
536
|
+
it("kills the specified terminal", async () => {
|
|
537
|
+
const res = await app.request("/api/terminal/kill", {
|
|
538
|
+
method: "POST",
|
|
539
|
+
headers: { "Content-Type": "application/json" },
|
|
540
|
+
body: JSON.stringify({ terminalId: "t-42" }),
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
expect(res.status).toBe(200);
|
|
544
|
+
const json = await res.json();
|
|
545
|
+
expect(json.ok).toBe(true);
|
|
546
|
+
expect(terminalManager.kill).toHaveBeenCalledWith("t-42");
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it("returns 400 when terminalId is missing", async () => {
|
|
550
|
+
const res = await app.request("/api/terminal/kill", {
|
|
551
|
+
method: "POST",
|
|
552
|
+
headers: { "Content-Type": "application/json" },
|
|
553
|
+
body: JSON.stringify({}),
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
expect(res.status).toBe(400);
|
|
557
|
+
const json = await res.json();
|
|
558
|
+
expect(json.error).toMatch(/terminalId/i);
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
563
|
+
// POST /api/sessions/:id/message
|
|
564
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
565
|
+
|
|
566
|
+
describe("POST /api/sessions/:id/message", () => {
|
|
567
|
+
it("injects a user message into a running session", async () => {
|
|
568
|
+
launcher.getSession.mockReturnValue({ id: "sess-1" } as any);
|
|
569
|
+
launcher.isAlive.mockReturnValue(true);
|
|
570
|
+
|
|
571
|
+
const res = await app.request("/api/sessions/sess-1/message", {
|
|
572
|
+
method: "POST",
|
|
573
|
+
headers: { "Content-Type": "application/json" },
|
|
574
|
+
body: JSON.stringify({ content: "hello world" }),
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
expect(res.status).toBe(200);
|
|
578
|
+
const json = await res.json();
|
|
579
|
+
expect(json.ok).toBe(true);
|
|
580
|
+
expect(json.sessionId).toBe("sess-1");
|
|
581
|
+
expect(wsBridge.injectUserMessage).toHaveBeenCalledWith("sess-1", "hello world");
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("returns 404 when the session does not exist", async () => {
|
|
585
|
+
launcher.getSession.mockReturnValue(undefined);
|
|
586
|
+
|
|
587
|
+
const res = await app.request("/api/sessions/missing/message", {
|
|
588
|
+
method: "POST",
|
|
589
|
+
headers: { "Content-Type": "application/json" },
|
|
590
|
+
body: JSON.stringify({ content: "hello" }),
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
expect(res.status).toBe(404);
|
|
594
|
+
const json = await res.json();
|
|
595
|
+
expect(json.error).toMatch(/not found/i);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it("returns 400 when the session is not running", async () => {
|
|
599
|
+
launcher.getSession.mockReturnValue({ id: "sess-1" } as any);
|
|
600
|
+
launcher.isAlive.mockReturnValue(false);
|
|
601
|
+
|
|
602
|
+
const res = await app.request("/api/sessions/sess-1/message", {
|
|
603
|
+
method: "POST",
|
|
604
|
+
headers: { "Content-Type": "application/json" },
|
|
605
|
+
body: JSON.stringify({ content: "hello" }),
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
expect(res.status).toBe(400);
|
|
609
|
+
const json = await res.json();
|
|
610
|
+
expect(json.error).toMatch(/not running/i);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("returns 400 when content is missing or empty", async () => {
|
|
614
|
+
launcher.getSession.mockReturnValue({ id: "sess-1" } as any);
|
|
615
|
+
launcher.isAlive.mockReturnValue(true);
|
|
616
|
+
|
|
617
|
+
// Empty content field
|
|
618
|
+
const res1 = await app.request("/api/sessions/sess-1/message", {
|
|
619
|
+
method: "POST",
|
|
620
|
+
headers: { "Content-Type": "application/json" },
|
|
621
|
+
body: JSON.stringify({ content: " " }),
|
|
622
|
+
});
|
|
623
|
+
expect(res1.status).toBe(400);
|
|
624
|
+
const json1 = await res1.json();
|
|
625
|
+
expect(json1.error).toMatch(/content/i);
|
|
626
|
+
|
|
627
|
+
// Missing content field entirely
|
|
628
|
+
const res2 = await app.request("/api/sessions/sess-1/message", {
|
|
629
|
+
method: "POST",
|
|
630
|
+
headers: { "Content-Type": "application/json" },
|
|
631
|
+
body: JSON.stringify({}),
|
|
632
|
+
});
|
|
633
|
+
expect(res2.status).toBe(400);
|
|
634
|
+
const json2 = await res2.json();
|
|
635
|
+
expect(json2.error).toMatch(/content/i);
|
|
636
|
+
});
|
|
637
|
+
});
|