@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,881 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ─── Mock cron-store module ─────────────────────────────────────────────────
|
|
4
|
+
// Mocked before imports so every `import` of cron-store gets the mock.
|
|
5
|
+
vi.mock("../cron-store.js", () => ({
|
|
6
|
+
listJobs: vi.fn(() => []),
|
|
7
|
+
getJob: vi.fn(() => null),
|
|
8
|
+
createJob: vi.fn(),
|
|
9
|
+
updateJob: vi.fn(),
|
|
10
|
+
deleteJob: vi.fn(() => false),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import { Hono } from "hono";
|
|
14
|
+
import * as cronStore from "../cron-store.js";
|
|
15
|
+
import type { CronJob, CronJobExecution } from "../cron-types.js";
|
|
16
|
+
import { registerCronRoutes } from "./cron-routes.js";
|
|
17
|
+
|
|
18
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/** Minimal CronJob fixture with sensible defaults. Override fields as needed. */
|
|
21
|
+
function makeJob(overrides: Partial<CronJob> = {}): CronJob {
|
|
22
|
+
return {
|
|
23
|
+
id: "test-job",
|
|
24
|
+
name: "Test Job",
|
|
25
|
+
prompt: "Run a task",
|
|
26
|
+
schedule: "0 * * * *",
|
|
27
|
+
recurring: true,
|
|
28
|
+
backendType: "claude",
|
|
29
|
+
model: "claude-sonnet-4-6",
|
|
30
|
+
cwd: "/tmp/test",
|
|
31
|
+
enabled: true,
|
|
32
|
+
permissionMode: "bypassPermissions",
|
|
33
|
+
createdAt: 1000,
|
|
34
|
+
updatedAt: 2000,
|
|
35
|
+
consecutiveFailures: 0,
|
|
36
|
+
totalRuns: 0,
|
|
37
|
+
...overrides,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Build a mock CronScheduler with vi.fn() stubs for every method the routes use. */
|
|
42
|
+
function createMockScheduler() {
|
|
43
|
+
return {
|
|
44
|
+
getNextRunTime: vi.fn((_id?: string) => null as Date | null),
|
|
45
|
+
scheduleJob: vi.fn(),
|
|
46
|
+
stopJob: vi.fn(),
|
|
47
|
+
executeJobManually: vi.fn(),
|
|
48
|
+
getExecutions: vi.fn(() => [] as CronJobExecution[]),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Test setup ─────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
let app: Hono;
|
|
55
|
+
let scheduler: ReturnType<typeof createMockScheduler>;
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
vi.clearAllMocks();
|
|
59
|
+
|
|
60
|
+
scheduler = createMockScheduler();
|
|
61
|
+
|
|
62
|
+
// Create a Hono app and mount cron routes under /api
|
|
63
|
+
app = new Hono();
|
|
64
|
+
const api = new Hono();
|
|
65
|
+
registerCronRoutes(api, scheduler as any);
|
|
66
|
+
app.route("/api", api);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ─── GET /api/cron/jobs ─────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
describe("GET /api/cron/jobs", () => {
|
|
72
|
+
it("returns an empty list when no jobs exist", async () => {
|
|
73
|
+
// Validate that an empty store returns []
|
|
74
|
+
vi.mocked(cronStore.listJobs).mockReturnValue([]);
|
|
75
|
+
|
|
76
|
+
const res = await app.request("/api/cron/jobs");
|
|
77
|
+
|
|
78
|
+
expect(res.status).toBe(200);
|
|
79
|
+
const json = await res.json();
|
|
80
|
+
expect(json).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("returns the list of jobs enriched with nextRunAt", async () => {
|
|
84
|
+
// When the scheduler has a next run time, the response should include it as epoch ms
|
|
85
|
+
const job = makeJob();
|
|
86
|
+
vi.mocked(cronStore.listJobs).mockReturnValue([job]);
|
|
87
|
+
const nextRun = new Date("2026-03-01T00:00:00Z");
|
|
88
|
+
scheduler.getNextRunTime.mockReturnValue(nextRun);
|
|
89
|
+
|
|
90
|
+
const res = await app.request("/api/cron/jobs");
|
|
91
|
+
|
|
92
|
+
expect(res.status).toBe(200);
|
|
93
|
+
const json = await res.json();
|
|
94
|
+
expect(json).toHaveLength(1);
|
|
95
|
+
expect(json[0].id).toBe("test-job");
|
|
96
|
+
expect(json[0].nextRunAt).toBe(nextRun.getTime());
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns nextRunAt as null when the scheduler has no next run time", async () => {
|
|
100
|
+
// If the scheduler returns null (e.g. job is disabled or one-shot already ran),
|
|
101
|
+
// the enriched field should be null rather than omitted
|
|
102
|
+
const job = makeJob({ enabled: false });
|
|
103
|
+
vi.mocked(cronStore.listJobs).mockReturnValue([job]);
|
|
104
|
+
scheduler.getNextRunTime.mockReturnValue(null);
|
|
105
|
+
|
|
106
|
+
const res = await app.request("/api/cron/jobs");
|
|
107
|
+
|
|
108
|
+
const json = await res.json();
|
|
109
|
+
expect(json[0].nextRunAt).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("enriches multiple jobs independently", async () => {
|
|
113
|
+
// Each job should get its own nextRunAt based on its id
|
|
114
|
+
const job1 = makeJob({ id: "job-a", name: "Job A" });
|
|
115
|
+
const job2 = makeJob({ id: "job-b", name: "Job B" });
|
|
116
|
+
vi.mocked(cronStore.listJobs).mockReturnValue([job1, job2]);
|
|
117
|
+
|
|
118
|
+
const dateA = new Date("2026-04-01T08:00:00Z");
|
|
119
|
+
scheduler.getNextRunTime.mockImplementation((id?: string) => {
|
|
120
|
+
if (id === "job-a") return dateA;
|
|
121
|
+
return null;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const res = await app.request("/api/cron/jobs");
|
|
125
|
+
const json = await res.json();
|
|
126
|
+
|
|
127
|
+
expect(json).toHaveLength(2);
|
|
128
|
+
expect(json[0].nextRunAt).toBe(dateA.getTime());
|
|
129
|
+
expect(json[1].nextRunAt).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ─── GET /api/cron/jobs/:id ─────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
describe("GET /api/cron/jobs/:id", () => {
|
|
136
|
+
it("returns the job when it exists", async () => {
|
|
137
|
+
const job = makeJob({ id: "existing" });
|
|
138
|
+
vi.mocked(cronStore.getJob).mockReturnValue(job);
|
|
139
|
+
|
|
140
|
+
const res = await app.request("/api/cron/jobs/existing");
|
|
141
|
+
|
|
142
|
+
expect(res.status).toBe(200);
|
|
143
|
+
const json = await res.json();
|
|
144
|
+
expect(json.id).toBe("existing");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("returns 404 when the job does not exist", async () => {
|
|
148
|
+
vi.mocked(cronStore.getJob).mockReturnValue(null);
|
|
149
|
+
|
|
150
|
+
const res = await app.request("/api/cron/jobs/nonexistent");
|
|
151
|
+
|
|
152
|
+
expect(res.status).toBe(404);
|
|
153
|
+
const json = await res.json();
|
|
154
|
+
expect(json.error).toBe("Job not found");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("enriches the job with nextRunAt from the scheduler", async () => {
|
|
158
|
+
// The single-job endpoint should also attach the next run time
|
|
159
|
+
const job = makeJob({ id: "scheduled" });
|
|
160
|
+
vi.mocked(cronStore.getJob).mockReturnValue(job);
|
|
161
|
+
const nextRun = new Date("2026-06-15T12:00:00Z");
|
|
162
|
+
scheduler.getNextRunTime.mockReturnValue(nextRun);
|
|
163
|
+
|
|
164
|
+
const res = await app.request("/api/cron/jobs/scheduled");
|
|
165
|
+
|
|
166
|
+
const json = await res.json();
|
|
167
|
+
expect(json.nextRunAt).toBe(nextRun.getTime());
|
|
168
|
+
expect(scheduler.getNextRunTime).toHaveBeenCalledWith("scheduled");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("returns nextRunAt as null when the scheduler has no timer for the job", async () => {
|
|
172
|
+
const job = makeJob({ id: "no-timer" });
|
|
173
|
+
vi.mocked(cronStore.getJob).mockReturnValue(job);
|
|
174
|
+
scheduler.getNextRunTime.mockReturnValue(null);
|
|
175
|
+
|
|
176
|
+
const res = await app.request("/api/cron/jobs/no-timer");
|
|
177
|
+
|
|
178
|
+
const json = await res.json();
|
|
179
|
+
expect(json.nextRunAt).toBeNull();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ─── POST /api/cron/jobs ────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
describe("POST /api/cron/jobs", () => {
|
|
186
|
+
it("creates a job and returns 201", async () => {
|
|
187
|
+
const created = makeJob({ id: "my-job", name: "My Job" });
|
|
188
|
+
vi.mocked(cronStore.createJob).mockReturnValue(created);
|
|
189
|
+
|
|
190
|
+
const res = await app.request("/api/cron/jobs", {
|
|
191
|
+
method: "POST",
|
|
192
|
+
headers: { "Content-Type": "application/json" },
|
|
193
|
+
body: JSON.stringify({
|
|
194
|
+
name: "My Job",
|
|
195
|
+
prompt: "Hello",
|
|
196
|
+
schedule: "0 * * * *",
|
|
197
|
+
cwd: "/tmp",
|
|
198
|
+
}),
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(res.status).toBe(201);
|
|
202
|
+
const json = await res.json();
|
|
203
|
+
expect(json.id).toBe("my-job");
|
|
204
|
+
expect(cronStore.createJob).toHaveBeenCalledTimes(1);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("passes all user-supplied fields to createJob", async () => {
|
|
208
|
+
// Ensure every field from the request body is forwarded to the store
|
|
209
|
+
const created = makeJob();
|
|
210
|
+
vi.mocked(cronStore.createJob).mockReturnValue(created);
|
|
211
|
+
|
|
212
|
+
await app.request("/api/cron/jobs", {
|
|
213
|
+
method: "POST",
|
|
214
|
+
headers: { "Content-Type": "application/json" },
|
|
215
|
+
body: JSON.stringify({
|
|
216
|
+
name: "Full Config",
|
|
217
|
+
prompt: "Do everything",
|
|
218
|
+
schedule: "*/5 * * * *",
|
|
219
|
+
recurring: false,
|
|
220
|
+
backendType: "codex",
|
|
221
|
+
model: "o4-mini",
|
|
222
|
+
cwd: "/home/user/project",
|
|
223
|
+
envSlug: "production",
|
|
224
|
+
enabled: false,
|
|
225
|
+
permissionMode: "default",
|
|
226
|
+
codexInternetAccess: true,
|
|
227
|
+
}),
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(cronStore.createJob).toHaveBeenCalledWith({
|
|
231
|
+
name: "Full Config",
|
|
232
|
+
prompt: "Do everything",
|
|
233
|
+
schedule: "*/5 * * * *",
|
|
234
|
+
recurring: false,
|
|
235
|
+
backendType: "codex",
|
|
236
|
+
model: "o4-mini",
|
|
237
|
+
cwd: "/home/user/project",
|
|
238
|
+
envSlug: "production",
|
|
239
|
+
enabled: false,
|
|
240
|
+
permissionMode: "default",
|
|
241
|
+
codexInternetAccess: true,
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("schedules the job when enabled", async () => {
|
|
246
|
+
// When a new job is created with enabled=true, the scheduler should be called
|
|
247
|
+
const created = makeJob({ enabled: true });
|
|
248
|
+
vi.mocked(cronStore.createJob).mockReturnValue(created);
|
|
249
|
+
|
|
250
|
+
await app.request("/api/cron/jobs", {
|
|
251
|
+
method: "POST",
|
|
252
|
+
headers: { "Content-Type": "application/json" },
|
|
253
|
+
body: JSON.stringify({
|
|
254
|
+
name: "Enabled Job",
|
|
255
|
+
prompt: "Run",
|
|
256
|
+
schedule: "*/5 * * * *",
|
|
257
|
+
cwd: "/tmp",
|
|
258
|
+
}),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(scheduler.scheduleJob).toHaveBeenCalledWith(created);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("does not schedule the job when disabled", async () => {
|
|
265
|
+
// When a new job is created with enabled=false, the scheduler should NOT be called
|
|
266
|
+
const created = makeJob({ enabled: false });
|
|
267
|
+
vi.mocked(cronStore.createJob).mockReturnValue(created);
|
|
268
|
+
|
|
269
|
+
await app.request("/api/cron/jobs", {
|
|
270
|
+
method: "POST",
|
|
271
|
+
headers: { "Content-Type": "application/json" },
|
|
272
|
+
body: JSON.stringify({
|
|
273
|
+
name: "Disabled Job",
|
|
274
|
+
prompt: "Run",
|
|
275
|
+
schedule: "*/5 * * * *",
|
|
276
|
+
cwd: "/tmp",
|
|
277
|
+
enabled: false,
|
|
278
|
+
}),
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
expect(scheduler.scheduleJob).not.toHaveBeenCalled();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("returns 400 when the store throws a validation error", async () => {
|
|
285
|
+
// e.g. missing required fields
|
|
286
|
+
vi.mocked(cronStore.createJob).mockImplementation(() => {
|
|
287
|
+
throw new Error("Job name is required");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const res = await app.request("/api/cron/jobs", {
|
|
291
|
+
method: "POST",
|
|
292
|
+
headers: { "Content-Type": "application/json" },
|
|
293
|
+
body: JSON.stringify({}),
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
expect(res.status).toBe(400);
|
|
297
|
+
const json = await res.json();
|
|
298
|
+
expect(json.error).toBe("Job name is required");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("returns 400 when a duplicate job name is used", async () => {
|
|
302
|
+
vi.mocked(cronStore.createJob).mockImplementation(() => {
|
|
303
|
+
throw new Error('A job with a similar name already exists ("my-job")');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const res = await app.request("/api/cron/jobs", {
|
|
307
|
+
method: "POST",
|
|
308
|
+
headers: { "Content-Type": "application/json" },
|
|
309
|
+
body: JSON.stringify({
|
|
310
|
+
name: "My Job",
|
|
311
|
+
prompt: "Do stuff",
|
|
312
|
+
schedule: "0 * * * *",
|
|
313
|
+
cwd: "/tmp",
|
|
314
|
+
}),
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
expect(res.status).toBe(400);
|
|
318
|
+
const json = await res.json();
|
|
319
|
+
expect(json.error).toContain("already exists");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("handles invalid JSON body gracefully", async () => {
|
|
323
|
+
// The route catches JSON parse errors via .catch(() => ({})),
|
|
324
|
+
// so it should fall through to createJob with empty strings
|
|
325
|
+
vi.mocked(cronStore.createJob).mockImplementation(() => {
|
|
326
|
+
throw new Error("Job name is required");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const res = await app.request("/api/cron/jobs", {
|
|
330
|
+
method: "POST",
|
|
331
|
+
headers: { "Content-Type": "application/json" },
|
|
332
|
+
body: "not valid json",
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
expect(res.status).toBe(400);
|
|
336
|
+
const json = await res.json();
|
|
337
|
+
expect(json.error).toBeTruthy();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("uses sensible defaults for optional fields", async () => {
|
|
341
|
+
// When optional fields are omitted, the route should fill in defaults
|
|
342
|
+
const created = makeJob();
|
|
343
|
+
vi.mocked(cronStore.createJob).mockReturnValue(created);
|
|
344
|
+
|
|
345
|
+
await app.request("/api/cron/jobs", {
|
|
346
|
+
method: "POST",
|
|
347
|
+
headers: { "Content-Type": "application/json" },
|
|
348
|
+
body: JSON.stringify({
|
|
349
|
+
name: "Minimal Job",
|
|
350
|
+
prompt: "Do something",
|
|
351
|
+
schedule: "0 * * * *",
|
|
352
|
+
cwd: "/tmp",
|
|
353
|
+
}),
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const passedInput = vi.mocked(cronStore.createJob).mock.calls[0][0];
|
|
357
|
+
// Check default values for optional fields
|
|
358
|
+
expect(passedInput.recurring).toBe(true);
|
|
359
|
+
expect(passedInput.backendType).toBe("claude");
|
|
360
|
+
expect(passedInput.model).toBe("");
|
|
361
|
+
expect(passedInput.enabled).toBe(true);
|
|
362
|
+
expect(passedInput.permissionMode).toBe("bypassPermissions");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("converts non-Error thrown values to string in the 400 response", async () => {
|
|
366
|
+
// Edge case: the store throws a non-Error value (e.g. a string)
|
|
367
|
+
vi.mocked(cronStore.createJob).mockImplementation(() => {
|
|
368
|
+
throw "raw string error";
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const res = await app.request("/api/cron/jobs", {
|
|
372
|
+
method: "POST",
|
|
373
|
+
headers: { "Content-Type": "application/json" },
|
|
374
|
+
body: JSON.stringify({ name: "Bad Job" }),
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
expect(res.status).toBe(400);
|
|
378
|
+
const json = await res.json();
|
|
379
|
+
expect(json.error).toBe("raw string error");
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// ─── PUT /api/cron/jobs/:id ─────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
describe("PUT /api/cron/jobs/:id", () => {
|
|
386
|
+
it("updates the job and returns the updated version", async () => {
|
|
387
|
+
const updated = makeJob({ id: "test-job", name: "Updated Name" });
|
|
388
|
+
vi.mocked(cronStore.updateJob).mockReturnValue(updated);
|
|
389
|
+
|
|
390
|
+
const res = await app.request("/api/cron/jobs/test-job", {
|
|
391
|
+
method: "PUT",
|
|
392
|
+
headers: { "Content-Type": "application/json" },
|
|
393
|
+
body: JSON.stringify({ name: "Updated Name" }),
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
expect(res.status).toBe(200);
|
|
397
|
+
const json = await res.json();
|
|
398
|
+
expect(json.name).toBe("Updated Name");
|
|
399
|
+
expect(cronStore.updateJob).toHaveBeenCalledWith(
|
|
400
|
+
"test-job",
|
|
401
|
+
expect.objectContaining({ name: "Updated Name" }),
|
|
402
|
+
);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("returns 404 when job does not exist", async () => {
|
|
406
|
+
vi.mocked(cronStore.updateJob).mockReturnValue(null);
|
|
407
|
+
|
|
408
|
+
const res = await app.request("/api/cron/jobs/nonexistent", {
|
|
409
|
+
method: "PUT",
|
|
410
|
+
headers: { "Content-Type": "application/json" },
|
|
411
|
+
body: JSON.stringify({ name: "Nope" }),
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
expect(res.status).toBe(404);
|
|
415
|
+
const json = await res.json();
|
|
416
|
+
expect(json.error).toBe("Job not found");
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("strips non-editable fields from the update payload", async () => {
|
|
420
|
+
// Fields like 'id', 'createdAt', 'totalRuns', 'consecutiveFailures'
|
|
421
|
+
// should NOT be passed through to updateJob — only the whitelist applies
|
|
422
|
+
const updated = makeJob();
|
|
423
|
+
vi.mocked(cronStore.updateJob).mockReturnValue(updated);
|
|
424
|
+
|
|
425
|
+
await app.request("/api/cron/jobs/test-job", {
|
|
426
|
+
method: "PUT",
|
|
427
|
+
headers: { "Content-Type": "application/json" },
|
|
428
|
+
body: JSON.stringify({
|
|
429
|
+
name: "Good Field",
|
|
430
|
+
id: "hacked-id",
|
|
431
|
+
createdAt: 9999,
|
|
432
|
+
updatedAt: 8888,
|
|
433
|
+
totalRuns: 999,
|
|
434
|
+
consecutiveFailures: 100,
|
|
435
|
+
lastRunAt: 7777,
|
|
436
|
+
lastSessionId: "sess-hacked",
|
|
437
|
+
}),
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const passedUpdates = vi.mocked(cronStore.updateJob).mock.calls[0][1];
|
|
441
|
+
// Allowed fields should be present
|
|
442
|
+
expect(passedUpdates).toHaveProperty("name", "Good Field");
|
|
443
|
+
// Non-editable fields should be stripped by the whitelist filter
|
|
444
|
+
expect(passedUpdates).not.toHaveProperty("id");
|
|
445
|
+
expect(passedUpdates).not.toHaveProperty("createdAt");
|
|
446
|
+
expect(passedUpdates).not.toHaveProperty("updatedAt");
|
|
447
|
+
expect(passedUpdates).not.toHaveProperty("totalRuns");
|
|
448
|
+
expect(passedUpdates).not.toHaveProperty("consecutiveFailures");
|
|
449
|
+
expect(passedUpdates).not.toHaveProperty("lastRunAt");
|
|
450
|
+
expect(passedUpdates).not.toHaveProperty("lastSessionId");
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("allows all whitelisted fields through", async () => {
|
|
454
|
+
// The PUT handler allows: name, prompt, schedule, recurring, backendType,
|
|
455
|
+
// model, cwd, envSlug, enabled, permissionMode, codexInternetAccess
|
|
456
|
+
const updated = makeJob();
|
|
457
|
+
vi.mocked(cronStore.updateJob).mockReturnValue(updated);
|
|
458
|
+
|
|
459
|
+
const allowedPayload = {
|
|
460
|
+
name: "New Name",
|
|
461
|
+
prompt: "New Prompt",
|
|
462
|
+
schedule: "*/10 * * * *",
|
|
463
|
+
recurring: false,
|
|
464
|
+
backendType: "codex",
|
|
465
|
+
model: "o4-mini",
|
|
466
|
+
cwd: "/new/path",
|
|
467
|
+
envSlug: "staging",
|
|
468
|
+
enabled: false,
|
|
469
|
+
permissionMode: "default",
|
|
470
|
+
codexInternetAccess: true,
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
await app.request("/api/cron/jobs/test-job", {
|
|
474
|
+
method: "PUT",
|
|
475
|
+
headers: { "Content-Type": "application/json" },
|
|
476
|
+
body: JSON.stringify(allowedPayload),
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
const passedUpdates = vi.mocked(cronStore.updateJob).mock.calls[0][1];
|
|
480
|
+
for (const [key, value] of Object.entries(allowedPayload)) {
|
|
481
|
+
expect(passedUpdates).toHaveProperty(key, value);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("reschedules the job after update", async () => {
|
|
486
|
+
// After a successful update, the route should always call scheduleJob
|
|
487
|
+
const updated = makeJob({ id: "test-job" });
|
|
488
|
+
vi.mocked(cronStore.updateJob).mockReturnValue(updated);
|
|
489
|
+
|
|
490
|
+
await app.request("/api/cron/jobs/test-job", {
|
|
491
|
+
method: "PUT",
|
|
492
|
+
headers: { "Content-Type": "application/json" },
|
|
493
|
+
body: JSON.stringify({ schedule: "*/30 * * * *" }),
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
expect(scheduler.scheduleJob).toHaveBeenCalledWith(updated);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("stops the old timer when the job id changes (name rename)", async () => {
|
|
500
|
+
// If the update results in a new ID (because name changed), the route
|
|
501
|
+
// should stop the old timer before scheduling the new one
|
|
502
|
+
const updated = makeJob({ id: "new-name" });
|
|
503
|
+
vi.mocked(cronStore.updateJob).mockReturnValue(updated);
|
|
504
|
+
|
|
505
|
+
await app.request("/api/cron/jobs/old-name", {
|
|
506
|
+
method: "PUT",
|
|
507
|
+
headers: { "Content-Type": "application/json" },
|
|
508
|
+
body: JSON.stringify({ name: "New Name" }),
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// Should stop the old id's timer since updated.id !== param id
|
|
512
|
+
expect(scheduler.stopJob).toHaveBeenCalledWith("old-name");
|
|
513
|
+
// And schedule the updated job
|
|
514
|
+
expect(scheduler.scheduleJob).toHaveBeenCalledWith(updated);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("does not stop the old timer when the id stays the same", async () => {
|
|
518
|
+
// When the name doesn't change (or changes to produce the same slug),
|
|
519
|
+
// stopJob should NOT be called for the old id
|
|
520
|
+
const updated = makeJob({ id: "same-id" });
|
|
521
|
+
vi.mocked(cronStore.updateJob).mockReturnValue(updated);
|
|
522
|
+
|
|
523
|
+
await app.request("/api/cron/jobs/same-id", {
|
|
524
|
+
method: "PUT",
|
|
525
|
+
headers: { "Content-Type": "application/json" },
|
|
526
|
+
body: JSON.stringify({ prompt: "Updated prompt" }),
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// stopJob should not be called because updated.id === request param id
|
|
530
|
+
expect(scheduler.stopJob).not.toHaveBeenCalled();
|
|
531
|
+
expect(scheduler.scheduleJob).toHaveBeenCalledWith(updated);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("returns 400 when the store throws a validation error", async () => {
|
|
535
|
+
vi.mocked(cronStore.updateJob).mockImplementation(() => {
|
|
536
|
+
throw new Error("Job name must contain alphanumeric characters");
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
const res = await app.request("/api/cron/jobs/test-job", {
|
|
540
|
+
method: "PUT",
|
|
541
|
+
headers: { "Content-Type": "application/json" },
|
|
542
|
+
body: JSON.stringify({ name: "!!!" }),
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
expect(res.status).toBe(400);
|
|
546
|
+
const json = await res.json();
|
|
547
|
+
expect(json.error).toBe("Job name must contain alphanumeric characters");
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it("handles invalid JSON body gracefully", async () => {
|
|
551
|
+
// The route catches JSON parse errors via .catch(() => ({})),
|
|
552
|
+
// then passes an empty allowed-set to updateJob
|
|
553
|
+
vi.mocked(cronStore.updateJob).mockReturnValue(null);
|
|
554
|
+
|
|
555
|
+
const res = await app.request("/api/cron/jobs/test-job", {
|
|
556
|
+
method: "PUT",
|
|
557
|
+
headers: { "Content-Type": "application/json" },
|
|
558
|
+
body: "not valid json",
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// updateJob returns null for missing job => 404
|
|
562
|
+
expect(res.status).toBe(404);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it("converts non-Error thrown values to string in the 400 response", async () => {
|
|
566
|
+
vi.mocked(cronStore.updateJob).mockImplementation(() => {
|
|
567
|
+
throw "raw string error";
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
const res = await app.request("/api/cron/jobs/test-job", {
|
|
571
|
+
method: "PUT",
|
|
572
|
+
headers: { "Content-Type": "application/json" },
|
|
573
|
+
body: JSON.stringify({ name: "Bad" }),
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
expect(res.status).toBe(400);
|
|
577
|
+
const json = await res.json();
|
|
578
|
+
expect(json.error).toBe("raw string error");
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// ─── DELETE /api/cron/jobs/:id ──────────────────────────────────────────────
|
|
583
|
+
|
|
584
|
+
describe("DELETE /api/cron/jobs/:id", () => {
|
|
585
|
+
it("deletes an existing job and stops its scheduler", async () => {
|
|
586
|
+
vi.mocked(cronStore.deleteJob).mockReturnValue(true);
|
|
587
|
+
|
|
588
|
+
const res = await app.request("/api/cron/jobs/test-job", { method: "DELETE" });
|
|
589
|
+
|
|
590
|
+
expect(res.status).toBe(200);
|
|
591
|
+
const json = await res.json();
|
|
592
|
+
expect(json.ok).toBe(true);
|
|
593
|
+
// stopJob should be called BEFORE deleteJob to clean up the timer
|
|
594
|
+
expect(scheduler.stopJob).toHaveBeenCalledWith("test-job");
|
|
595
|
+
expect(cronStore.deleteJob).toHaveBeenCalledWith("test-job");
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it("returns 404 when job does not exist", async () => {
|
|
599
|
+
vi.mocked(cronStore.deleteJob).mockReturnValue(false);
|
|
600
|
+
|
|
601
|
+
const res = await app.request("/api/cron/jobs/nonexistent", { method: "DELETE" });
|
|
602
|
+
|
|
603
|
+
expect(res.status).toBe(404);
|
|
604
|
+
const json = await res.json();
|
|
605
|
+
expect(json.error).toBe("Job not found");
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it("always calls stopJob even when deleteJob returns false", async () => {
|
|
609
|
+
// The route calls stopJob unconditionally before checking deleteJob result.
|
|
610
|
+
// This is intentional — clean up any stale timer even if the file is gone.
|
|
611
|
+
vi.mocked(cronStore.deleteJob).mockReturnValue(false);
|
|
612
|
+
|
|
613
|
+
await app.request("/api/cron/jobs/stale-job", { method: "DELETE" });
|
|
614
|
+
|
|
615
|
+
expect(scheduler.stopJob).toHaveBeenCalledWith("stale-job");
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// ─── POST /api/cron/jobs/:id/toggle ─────────────────────────────────────────
|
|
620
|
+
|
|
621
|
+
describe("POST /api/cron/jobs/:id/toggle", () => {
|
|
622
|
+
it("toggles an enabled job to disabled and stops the scheduler", async () => {
|
|
623
|
+
const job = makeJob({ id: "my-job", enabled: true });
|
|
624
|
+
const toggled = makeJob({ id: "my-job", enabled: false });
|
|
625
|
+
vi.mocked(cronStore.getJob).mockReturnValue(job);
|
|
626
|
+
vi.mocked(cronStore.updateJob).mockReturnValue(toggled);
|
|
627
|
+
|
|
628
|
+
const res = await app.request("/api/cron/jobs/my-job/toggle", { method: "POST" });
|
|
629
|
+
|
|
630
|
+
expect(res.status).toBe(200);
|
|
631
|
+
const json = await res.json();
|
|
632
|
+
expect(json.enabled).toBe(false);
|
|
633
|
+
// Should have called updateJob with enabled: false (opposite of current)
|
|
634
|
+
expect(cronStore.updateJob).toHaveBeenCalledWith("my-job", { enabled: false });
|
|
635
|
+
// When toggled off, should stop the scheduler for this job
|
|
636
|
+
expect(scheduler.stopJob).toHaveBeenCalledWith("my-job");
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it("toggles a disabled job to enabled and schedules it", async () => {
|
|
640
|
+
const job = makeJob({ id: "my-job", enabled: false });
|
|
641
|
+
const toggled = makeJob({ id: "my-job", enabled: true });
|
|
642
|
+
vi.mocked(cronStore.getJob).mockReturnValue(job);
|
|
643
|
+
vi.mocked(cronStore.updateJob).mockReturnValue(toggled);
|
|
644
|
+
|
|
645
|
+
const res = await app.request("/api/cron/jobs/my-job/toggle", { method: "POST" });
|
|
646
|
+
|
|
647
|
+
expect(res.status).toBe(200);
|
|
648
|
+
const json = await res.json();
|
|
649
|
+
expect(json.enabled).toBe(true);
|
|
650
|
+
// Should have called updateJob with enabled: true
|
|
651
|
+
expect(cronStore.updateJob).toHaveBeenCalledWith("my-job", { enabled: true });
|
|
652
|
+
// When toggled on, should schedule the job
|
|
653
|
+
expect(scheduler.scheduleJob).toHaveBeenCalledWith(toggled);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it("returns 404 when job does not exist", async () => {
|
|
657
|
+
vi.mocked(cronStore.getJob).mockReturnValue(null);
|
|
658
|
+
|
|
659
|
+
const res = await app.request("/api/cron/jobs/nonexistent/toggle", { method: "POST" });
|
|
660
|
+
|
|
661
|
+
expect(res.status).toBe(404);
|
|
662
|
+
const json = await res.json();
|
|
663
|
+
expect(json.error).toBe("Job not found");
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it("does not call scheduleJob when the toggle-on update returns a still-disabled job", async () => {
|
|
667
|
+
// Edge case: updateJob could theoretically return a job that's still disabled
|
|
668
|
+
// (e.g. if store logic overrides). The route checks updated?.enabled.
|
|
669
|
+
const job = makeJob({ id: "my-job", enabled: false });
|
|
670
|
+
const stillDisabled = makeJob({ id: "my-job", enabled: false });
|
|
671
|
+
vi.mocked(cronStore.getJob).mockReturnValue(job);
|
|
672
|
+
vi.mocked(cronStore.updateJob).mockReturnValue(stillDisabled);
|
|
673
|
+
|
|
674
|
+
await app.request("/api/cron/jobs/my-job/toggle", { method: "POST" });
|
|
675
|
+
|
|
676
|
+
// updated?.enabled is false, so stopJob is called instead of scheduleJob
|
|
677
|
+
expect(scheduler.stopJob).toHaveBeenCalledWith("my-job");
|
|
678
|
+
expect(scheduler.scheduleJob).not.toHaveBeenCalled();
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
// ─── POST /api/cron/jobs/:id/run ────────────────────────────────────────────
|
|
683
|
+
|
|
684
|
+
describe("POST /api/cron/jobs/:id/run", () => {
|
|
685
|
+
it("triggers a manual job run", async () => {
|
|
686
|
+
const job = makeJob({ id: "runner" });
|
|
687
|
+
vi.mocked(cronStore.getJob).mockReturnValue(job);
|
|
688
|
+
|
|
689
|
+
const res = await app.request("/api/cron/jobs/runner/run", { method: "POST" });
|
|
690
|
+
|
|
691
|
+
expect(res.status).toBe(200);
|
|
692
|
+
const json = await res.json();
|
|
693
|
+
expect(json.ok).toBe(true);
|
|
694
|
+
expect(json.message).toBe("Job triggered");
|
|
695
|
+
expect(scheduler.executeJobManually).toHaveBeenCalledWith("runner");
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("returns 404 when job does not exist", async () => {
|
|
699
|
+
vi.mocked(cronStore.getJob).mockReturnValue(null);
|
|
700
|
+
|
|
701
|
+
const res = await app.request("/api/cron/jobs/nonexistent/run", { method: "POST" });
|
|
702
|
+
|
|
703
|
+
expect(res.status).toBe(404);
|
|
704
|
+
const json = await res.json();
|
|
705
|
+
expect(json.error).toBe("Job not found");
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
it("triggers a run even for disabled jobs", async () => {
|
|
709
|
+
// Manual run should work regardless of enabled state — the scheduler's
|
|
710
|
+
// executeJobManually handles the force flag internally
|
|
711
|
+
const job = makeJob({ id: "disabled-runner", enabled: false });
|
|
712
|
+
vi.mocked(cronStore.getJob).mockReturnValue(job);
|
|
713
|
+
|
|
714
|
+
const res = await app.request("/api/cron/jobs/disabled-runner/run", { method: "POST" });
|
|
715
|
+
|
|
716
|
+
expect(res.status).toBe(200);
|
|
717
|
+
expect(scheduler.executeJobManually).toHaveBeenCalledWith("disabled-runner");
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// ─── GET /api/cron/jobs/:id/executions ──────────────────────────────────────
|
|
722
|
+
|
|
723
|
+
describe("GET /api/cron/jobs/:id/executions", () => {
|
|
724
|
+
it("returns an empty list when no executions exist", async () => {
|
|
725
|
+
scheduler.getExecutions.mockReturnValue([]);
|
|
726
|
+
|
|
727
|
+
const res = await app.request("/api/cron/jobs/test-job/executions");
|
|
728
|
+
|
|
729
|
+
expect(res.status).toBe(200);
|
|
730
|
+
const json = await res.json();
|
|
731
|
+
expect(json).toEqual([]);
|
|
732
|
+
expect(scheduler.getExecutions).toHaveBeenCalledWith("test-job");
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it("returns the list of executions from the scheduler", async () => {
|
|
736
|
+
const executions: CronJobExecution[] = [
|
|
737
|
+
{
|
|
738
|
+
sessionId: "sess-1",
|
|
739
|
+
jobId: "test-job",
|
|
740
|
+
startedAt: 1000,
|
|
741
|
+
success: true,
|
|
742
|
+
},
|
|
743
|
+
{
|
|
744
|
+
sessionId: "sess-2",
|
|
745
|
+
jobId: "test-job",
|
|
746
|
+
startedAt: 2000,
|
|
747
|
+
completedAt: 2500,
|
|
748
|
+
error: "CLI process failed",
|
|
749
|
+
},
|
|
750
|
+
];
|
|
751
|
+
scheduler.getExecutions.mockReturnValue(executions);
|
|
752
|
+
|
|
753
|
+
const res = await app.request("/api/cron/jobs/test-job/executions");
|
|
754
|
+
|
|
755
|
+
expect(res.status).toBe(200);
|
|
756
|
+
const json = await res.json();
|
|
757
|
+
expect(json).toHaveLength(2);
|
|
758
|
+
expect(json[0].sessionId).toBe("sess-1");
|
|
759
|
+
expect(json[0].success).toBe(true);
|
|
760
|
+
expect(json[1].error).toBe("CLI process failed");
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it("returns empty array when scheduler is undefined", async () => {
|
|
764
|
+
// When registerCronRoutes is called without a scheduler (undefined),
|
|
765
|
+
// the optional chaining should safely return []
|
|
766
|
+
const appNoScheduler = new Hono();
|
|
767
|
+
const apiNoScheduler = new Hono();
|
|
768
|
+
registerCronRoutes(apiNoScheduler, undefined);
|
|
769
|
+
appNoScheduler.route("/api", apiNoScheduler);
|
|
770
|
+
|
|
771
|
+
const res = await appNoScheduler.request("/api/cron/jobs/any-job/executions");
|
|
772
|
+
|
|
773
|
+
expect(res.status).toBe(200);
|
|
774
|
+
const json = await res.json();
|
|
775
|
+
expect(json).toEqual([]);
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
// ─── Scheduler-less mode (cronScheduler is undefined) ───────────────────────
|
|
780
|
+
|
|
781
|
+
describe("routes with undefined cronScheduler", () => {
|
|
782
|
+
let appNoScheduler: Hono;
|
|
783
|
+
|
|
784
|
+
beforeEach(() => {
|
|
785
|
+
appNoScheduler = new Hono();
|
|
786
|
+
const api = new Hono();
|
|
787
|
+
registerCronRoutes(api, undefined);
|
|
788
|
+
appNoScheduler.route("/api", api);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
it("GET /cron/jobs returns jobs with nextRunAt: null", async () => {
|
|
792
|
+
// When there's no scheduler, nextRunAt should always be null
|
|
793
|
+
const job = makeJob();
|
|
794
|
+
vi.mocked(cronStore.listJobs).mockReturnValue([job]);
|
|
795
|
+
|
|
796
|
+
const res = await appNoScheduler.request("/api/cron/jobs");
|
|
797
|
+
|
|
798
|
+
const json = await res.json();
|
|
799
|
+
expect(json[0].nextRunAt).toBeNull();
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
it("GET /cron/jobs/:id returns job with nextRunAt: null", async () => {
|
|
803
|
+
const job = makeJob({ id: "test" });
|
|
804
|
+
vi.mocked(cronStore.getJob).mockReturnValue(job);
|
|
805
|
+
|
|
806
|
+
const res = await appNoScheduler.request("/api/cron/jobs/test");
|
|
807
|
+
|
|
808
|
+
const json = await res.json();
|
|
809
|
+
expect(json.nextRunAt).toBeNull();
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it("POST /cron/jobs creates job without calling scheduleJob", async () => {
|
|
813
|
+
const created = makeJob({ enabled: true });
|
|
814
|
+
vi.mocked(cronStore.createJob).mockReturnValue(created);
|
|
815
|
+
|
|
816
|
+
const res = await appNoScheduler.request("/api/cron/jobs", {
|
|
817
|
+
method: "POST",
|
|
818
|
+
headers: { "Content-Type": "application/json" },
|
|
819
|
+
body: JSON.stringify({
|
|
820
|
+
name: "Job",
|
|
821
|
+
prompt: "Do stuff",
|
|
822
|
+
schedule: "0 * * * *",
|
|
823
|
+
cwd: "/tmp",
|
|
824
|
+
}),
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
// Should still succeed — optional chaining means no error
|
|
828
|
+
expect(res.status).toBe(201);
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it("PUT /cron/jobs/:id updates job without calling scheduleJob", async () => {
|
|
832
|
+
const updated = makeJob({ id: "test-job" });
|
|
833
|
+
vi.mocked(cronStore.updateJob).mockReturnValue(updated);
|
|
834
|
+
|
|
835
|
+
const res = await appNoScheduler.request("/api/cron/jobs/test-job", {
|
|
836
|
+
method: "PUT",
|
|
837
|
+
headers: { "Content-Type": "application/json" },
|
|
838
|
+
body: JSON.stringify({ name: "Updated" }),
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
expect(res.status).toBe(200);
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it("DELETE /cron/jobs/:id deletes job without calling stopJob", async () => {
|
|
845
|
+
vi.mocked(cronStore.deleteJob).mockReturnValue(true);
|
|
846
|
+
|
|
847
|
+
const res = await appNoScheduler.request("/api/cron/jobs/test-job", {
|
|
848
|
+
method: "DELETE",
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
expect(res.status).toBe(200);
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
it("POST /cron/jobs/:id/toggle works without scheduler", async () => {
|
|
855
|
+
const job = makeJob({ id: "test", enabled: true });
|
|
856
|
+
const toggled = makeJob({ id: "test", enabled: false });
|
|
857
|
+
vi.mocked(cronStore.getJob).mockReturnValue(job);
|
|
858
|
+
vi.mocked(cronStore.updateJob).mockReturnValue(toggled);
|
|
859
|
+
|
|
860
|
+
const res = await appNoScheduler.request("/api/cron/jobs/test/toggle", {
|
|
861
|
+
method: "POST",
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
expect(res.status).toBe(200);
|
|
865
|
+
const json = await res.json();
|
|
866
|
+
expect(json.enabled).toBe(false);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it("POST /cron/jobs/:id/run works without scheduler", async () => {
|
|
870
|
+
const job = makeJob({ id: "test" });
|
|
871
|
+
vi.mocked(cronStore.getJob).mockReturnValue(job);
|
|
872
|
+
|
|
873
|
+
const res = await appNoScheduler.request("/api/cron/jobs/test/run", {
|
|
874
|
+
method: "POST",
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
expect(res.status).toBe(200);
|
|
878
|
+
const json = await res.json();
|
|
879
|
+
expect(json.ok).toBe(true);
|
|
880
|
+
});
|
|
881
|
+
});
|