| 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { |
| 3 | DEFAULT_SERVER_SETTINGS, |
| 4 | type ServerConfig, |
| 5 | ProviderDriverKind, |
| 6 | ProviderInstanceId, |
| 7 | ProjectId, |
| 8 | ScheduledTaskId, |
| 9 | type ScheduledTask, |
| 10 | } from "@t3tools/contracts"; |
| 11 | import { |
| 12 | scheduledTaskDefaultModel, |
| 13 | createDraft, |
| 14 | editDraft, |
| 15 | DEFAULT_SCHEDULE, |
| 16 | hasScheduledTaskDraftChanges, |
| 17 | scheduleDraftForTask, |
| 18 | scheduleFromDraft, |
| 19 | } from "./scheduledTaskDraft"; |
| 20 | |
| 21 | describe("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 | |
| 71 | describe("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 | |
| 142 | describe("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 | |
| 221 | const 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 | |
| 244 | describe("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 | |
| 260 | it("continues to default newly created tasks to origin", () => { |
| 261 | expect(createDraft(null, null).startFromOrigin).toBe(true); |
| 262 | }); |
| 263 | |
| 264 | describe("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 | }); |