1import { describe, expect, it } from "vite-plus/test";
2import {
3 DEFAULT_SERVER_SETTINGS,
4 type ServerConfig,
5 ProviderDriverKind,
6 ProviderInstanceId,
7 ProjectId,
8 ScheduledTaskId,
9 type ScheduledTask,
10} from "@t3tools/contracts";
11import {
12 scheduledTaskDefaultModel,
13 createDraft,
14 editDraft,
15 DEFAULT_SCHEDULE,
16 hasScheduledTaskDraftChanges,
17 scheduleDraftForTask,
18 scheduleFromDraft,
19} from "./scheduledTaskDraft";
20
21describe("scheduleDraftForTask", () => {
22 it("opens legacy sub-minute schedules at the writable minimum", () => {
23 const draft = scheduleDraftForTask({ schedule: { type: "interval", everyMs: 30_000 } });
24 expect(draft.intervalMinutes).toBe("1");
25 expect(scheduleFromDraft(draft)).toEqual({ type: "interval", everyMs: 60_000 });
26 });
27
28 it("preserves valid fractional-minute schedules through an edit", () => {
29 const schedule = { type: "interval" as const, everyMs: 65_000 };
30 expect(scheduleFromDraft(scheduleDraftForTask({ schedule }))).toEqual(schedule);
31 });
32
33 it("round-trips a webhook schedule without a signature", () => {
34 const draft = scheduleDraftForTask({ schedule: { type: "webhook", signature: null } });
35 expect(draft.mode).toBe("webhook");
36 expect(scheduleFromDraft(draft)).toEqual({
37 type: "webhook",
38 signature: null,
39 maxDeliveryAgeMinutes: null,
40 });
41 });
42
43 it("round-trips a webhook max age and treats blank input as no limit", () => {
44 const draft = scheduleDraftForTask({
45 schedule: { type: "webhook", signature: null, maxDeliveryAgeMinutes: 45 },
46 });
47 expect(draft.maxDeliveryAgeMinutes).toBe("45");
48 expect(scheduleFromDraft(draft)).toMatchObject({ maxDeliveryAgeMinutes: 45 });
49 // An invalid limit is an invalid schedule, never a silently removed one.
50 expect(scheduleFromDraft({ ...draft, maxDeliveryAgeMinutes: "1.5" })).toBeNull();
51 expect(scheduleFromDraft({ ...draft, maxDeliveryAgeMinutes: "0" })).toBeNull();
52 expect(scheduleFromDraft({ ...draft, maxDeliveryAgeMinutes: "" })).toMatchObject({
53 maxDeliveryAgeMinutes: null,
54 });
55 });
56
57 it("keeps a webhook signature on save without sending a secret", () => {
58 const signature = {
59 header: "x-hub-signature-256",
60 encoding: "hex" as const,
61 prefix: "sha256=",
62 };
63 const saved = scheduleFromDraft(
64 scheduleDraftForTask({ schedule: { type: "webhook", signature } }),
65 );
66 expect(saved).toEqual({ type: "webhook", signature, maxDeliveryAgeMinutes: null });
67 expect(saved?.type === "webhook" && saved.signature && "secret" in saved.signature).toBe(false);
68 });
69});
70
71describe("hasScheduledTaskDraftChanges", () => {
72 it("leaves an untouched form clean and clears changes when edits are reverted", () => {
73 const initial = createDraft(null, null);
74 const edited = { ...initial, prompt: "Review open issues" };
75 expect(hasScheduledTaskDraftChanges(initial, createDraft(null, null))).toBe(false);
76 expect(hasScheduledTaskDraftChanges(initial, edited)).toBe(true);
77 expect(hasScheduledTaskDraftChanges(initial, { ...edited, prompt: initial.prompt })).toBe(
78 false,
79 );
80 });
81
82 it("keeps invalid, unsaved schedule input dirty", () => {
83 const initial = createDraft(null, null);
84 expect(
85 hasScheduledTaskDraftChanges(initial, {
86 ...initial,
87 schedule: { ...initial.schedule, weekdays: [] },
88 }),
89 ).toBe(true);
90 });
91
92 it("does not warn after deselecting and reselecting the same weekdays", () => {
93 const initial = createDraft(null, null);
94 expect(
95 hasScheduledTaskDraftChanges(initial, {
96 ...initial,
97 schedule: { ...initial.schedule, weekdays: [2, 3, 4, 5, 1] },
98 }),
99 ).toBe(false);
100 });
101
102 it("detects changes from the branch and model pickers", () => {
103 const initial = createDraft(null, {
104 instanceId: ProviderInstanceId.make("codex"),
105 model: "gpt-5.4",
106 options: [
107 { id: "effort", value: "high" },
108 { id: "fastMode", value: false },
109 ],
110 });
111 expect(hasScheduledTaskDraftChanges(initial, { ...initial, baseRef: "release" })).toBe(true);
112 expect(hasScheduledTaskDraftChanges(initial, { ...initial, startFromOrigin: false })).toBe(
113 true,
114 );
115 expect(
116 hasScheduledTaskDraftChanges(initial, {
117 ...initial,
118 modelSelection: {
119 ...initial.modelSelection!,
120 options: [
121 { id: "fastMode", value: false },
122 { id: "effort", value: "high" },
123 ],
124 },
125 }),
126 ).toBe(false);
127 expect(
128 hasScheduledTaskDraftChanges(initial, {
129 ...initial,
130 modelSelection: {
131 ...initial.modelSelection!,
132 options: [
133 { id: "effort", value: "low" },
134 { id: "fastMode", value: false },
135 ],
136 },
137 }),
138 ).toBe(true);
139 });
140});
141
142describe("scheduleFromDraft", () => {
143 it("rejects an empty day selection rather than silently scheduling every day", () => {
144 expect(scheduleFromDraft({ ...DEFAULT_SCHEDULE, weekdays: [] })).toBeNull();
145 });
146
147 it("accepts a selected local time and sorts weekdays", () => {
148 expect(
149 scheduleFromDraft({ ...DEFAULT_SCHEDULE, timeOfDay: "18:30", weekdays: [5, 1] }),
150 ).toEqual({
151 type: "fixed_time",
152 timeOfDay: "18:30",
153 weekdays: [1, 5],
154 });
155 });
156
157 it("stores every day without a weekday restriction", () => {
158 expect(scheduleFromDraft({ ...DEFAULT_SCHEDULE, weekdays: [1, 2, 3, 4, 5, 6, 0] })).toEqual({
159 type: "fixed_time",
160 timeOfDay: "09:00",
161 });
162 });
163
164 it("does not add a missing run day when an existing schedule contains duplicates", () => {
165 expect(scheduleFromDraft({ ...DEFAULT_SCHEDULE, weekdays: [1, 2, 3, 4, 5, 6, 6] })).toEqual({
166 type: "fixed_time",
167 timeOfDay: "09:00",
168 weekdays: [1, 2, 3, 4, 5, 6],
169 });
170 expect(scheduleFromDraft({ ...DEFAULT_SCHEDULE, weekdays: [0, 1, 2, 3, 4, 5, 6, 6] })).toEqual({
171 type: "fixed_time",
172 timeOfDay: "09:00",
173 });
174 });
175
176 it.each([-1, 7, 1.5, NaN])(
177 "rejects invalid weekday %s instead of scheduling every day",
178 (day) => {
179 expect(
180 scheduleFromDraft({ ...DEFAULT_SCHEDULE, weekdays: [0, 1, 2, 3, 4, 5, day] }),
181 ).toBeNull();
182 },
183 );
184
185 it("rejects malformed times and sub-minute intervals", () => {
186 expect(scheduleFromDraft({ ...DEFAULT_SCHEDULE, timeOfDay: "25:00" })).toBeNull();
187 expect(
188 scheduleFromDraft({ ...DEFAULT_SCHEDULE, mode: "interval", intervalMinutes: "0" }),
189 ).toBeNull();
190 expect(
191 scheduleFromDraft({ ...DEFAULT_SCHEDULE, mode: "interval", intervalMinutes: "15" }),
192 ).toEqual({
193 type: "interval",
194 everyMs: 900_000,
195 });
196 });
197
198 it.each([
199 ["1.5", 90_000],
200 ["1000001", 60_000_060_000],
201 [String(60_001 / 60_000), 60_001],
202 [String(65_000 / 60_000), 65_000],
203 [String(123_456 / 60_000), 123_456],
204 ])("preserves a valid %s minute interval when saving", (intervalMinutes, everyMs) => {
205 expect(scheduleFromDraft({ ...DEFAULT_SCHEDULE, mode: "interval", intervalMinutes })).toEqual({
206 type: "interval",
207 everyMs,
208 });
209 });
210
211 it.each(["NaN", "Infinity", "9007199254740991", "0.5"])(
212 "rejects intervals that cannot be written as safe whole milliseconds: %s",
213 (intervalMinutes) => {
214 expect(
215 scheduleFromDraft({ ...DEFAULT_SCHEDULE, mode: "interval", intervalMinutes }),
216 ).toBeNull();
217 },
218 );
219});
220
221const legacyTask: ScheduledTask = {
222 id: ScheduledTaskId.make("legacy-task"),
223 title: "Review issues",
224 prompt: "Review open issues",
225 enabled: true,
226 schedule: { type: "interval", everyMs: 60_000 },
227 projectId: ProjectId.make("project"),
228 threadId: null,
229 workspaceStrategy: { type: "worktree", baseRef: "release" },
230 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
231 runtimeMode: "full-access",
232 interactionMode: "default",
233 createdBy: "user",
234 creationSource: "web",
235 createdAt: "2026-09-17T00:00:00.000Z",
236 updatedAt: "2026-09-17T00:00:00.000Z",
237 nextRunAt: null,
238 lastRunAt: null,
239 lastRunStatus: "never",
240 lastRunError: null,
241 runCount: 0,
242};
243
244describe("editing scheduled task branch settings", () => {
245 it("keeps an omitted origin flag on the local base branch", () => {
246 const draft = editDraft(legacyTask);
247 expect(draft.baseRef).toBe("release");
248 expect(draft.startFromOrigin).toBe(false);
249 });
250
251 it.each([true, false])("preserves an explicit origin flag of %s", (startFromOrigin) => {
252 const draft = editDraft({
253 ...legacyTask,
254 workspaceStrategy: { type: "worktree", baseRef: "release", startFromOrigin },
255 });
256 expect(draft.startFromOrigin).toBe(startFromOrigin);
257 });
258});
259
260it("continues to default newly created tasks to origin", () => {
261 expect(createDraft(null, null).startFromOrigin).toBe(true);
262});
263
264describe("scheduled task model defaults", () => {
265 const instanceId = ProviderInstanceId.make("codex");
266 const projectId = ProjectId.make("project");
267 const environmentSelection = {
268 instanceId,
269 model: "environment-model",
270 options: [{ id: "reasoning", value: "high" }],
271 };
272 const projectSelection = { instanceId, model: "project-model" };
273 const config = {
274 settings: { ...DEFAULT_SERVER_SETTINGS, defaultModelSelection: environmentSelection },
275 providers: [
276 {
277 instanceId,
278 driver: "codex",
279 displayName: "Codex",
280 enabled: true,
281 installed: true,
282 status: "ready",
283 auth: { status: "authenticated" },
284 models: [
285 { slug: "first-model", name: "First", isCustom: false, capabilities: null },
286 {
287 slug: "catalog-default",
288 name: "Default",
289 isDefault: true,
290 isCustom: false,
291 capabilities: null,
292 },
293 { slug: "environment-model", name: "Environment", isCustom: false, capabilities: null },
294 { slug: "project-model", name: "Project", isCustom: false, capabilities: null },
295 ],
296 },
297 ],
298 } as unknown as ServerConfig;
299 const resolve = scheduledTaskDefaultModel;
300 it("uses the environment default with its provider options", () => {
301 expect(resolve(config, { id: projectId })).toEqual(environmentSelection);
302 });
303 it("prefers the project's configured model", () => {
304 expect(resolve(config, { id: projectId, defaultModelSelection: projectSelection })).toEqual(
305 projectSelection,
306 );
307 expect(
308 resolve(
309 {
310 ...config,
311 settings: {
312 ...config.settings,
313 projectSettingsOverrides: {
314 [projectId]: { defaultModelSelection: projectSelection },
315 },
316 },
317 },
318 { id: projectId },
319 ),
320 ).toEqual(projectSelection);
321 });
322 it("uses the advertised default instead of catalog order when no default is configured", () => {
323 expect(
324 resolve({ ...config, settings: { ...config.settings, defaultModelSelection: null } }, null),
325 ).toEqual({ instanceId, model: "catalog-default" });
326 });
327 it("falls back to the environment default when the project provider is unavailable", () => {
328 expect(
329 resolve(config, {
330 id: projectId,
331 defaultModelSelection: {
332 instanceId: ProviderInstanceId.make("unavailable"),
333 model: "missing",
334 },
335 }),
336 ).toEqual(environmentSelection);
337 });
338 it("does not choose an implicit model on a disabled provider", () => {
339 expect(
340 resolve(
341 {
342 ...config,
343 settings: {
344 ...config.settings,
345 providerInstances: {
346 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false },
347 },
348 },
349 providers: config.providers.map((provider) => ({ ...provider, enabled: false })),
350 },
351 null,
352 ),
353 ).toBeNull();
354 });
355});