1import {
2 EnvironmentId,
3 ProjectId,
4 DEFAULT_SERVER_SETTINGS,
5 type ServerConfig,
6 ProviderInstanceId,
7 ScheduledTaskId,
8 type ScheduledTask,
9} from "@t3tools/contracts";
10import { describe, expect, it } from "vite-plus/test";
11
12import type {
13 SidebarProjectGroupMember,
14 SidebarProjectSnapshot,
15} from "../../sidebarProjectGrouping";
16import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope";
17
18import { deriveProviderInstanceEntries } from "../../providerInstances";
19import {
20 scheduledTaskDefaultModel,
21 matchesScheduledTaskScope,
22 scheduleFromDraft,
23 taskToDraft,
24} from "./scheduledTasksSettings.logic";
25
26const laptopId = EnvironmentId.make("laptop");
27const serverId = EnvironmentId.make("server");
28const environments = [
29 { environmentId: laptopId, label: "Laptop" },
30 { environmentId: serverId, label: "Server" },
31];
32
33function member(id: string, environmentId: EnvironmentId): SidebarProjectGroupMember {
34 return {
35 id: ProjectId.make(id),
36 environmentId,
37 title: "T3 Code",
38 workspaceRoot: `/repos/${id}`,
39 physicalProjectKey: `${environmentId}:/repos/${id}`,
40 environmentLabel:
41 environments.find((environment) => environment.environmentId === environmentId)?.label ??
42 null,
43 defaultModelSelection: null,
44 scripts: [],
45 createdAt: "2026-09-07T00:00:00.000Z",
46 updatedAt: "2026-09-07T00:00:00.000Z",
47 };
48}
49
50const first = member("first", laptopId);
51const second = member("second", laptopId);
52const third = member("third", serverId);
53const other = member("other", serverId);
54
55function group(
56 projectKey: string,
57 members: readonly SidebarProjectGroupMember[],
58): SidebarProjectSnapshot {
59 return {
60 ...members[0]!,
61 projectKey,
62 displayName: projectKey,
63 memberProjects: members,
64 memberProjectRefs: members.map((project) => ({
65 environmentId: project.environmentId,
66 projectId: project.id,
67 })),
68 groupedProjectCount: members.length,
69 environmentPresence: "mixed",
70 allRemoteMembersAreDesktopLocal: false,
71 allRemoteMembersAreWsl: false,
72 remoteEnvironmentLabels: [],
73 };
74}
75
76// Project IDs are environment-local. This unrelated server checkout deliberately
77// shares an ID with a laptop checkout in the selected group.
78const sameIdElsewhere = member("first", serverId);
79const groups = [group("t3code", [first, second, third]), group("other", [other, sameIdElsewhere])];
80const tasks = [first, second, third, other, sameIdElsewhere].map((project, index) => ({
81 id: `task-${index}`,
82 environmentId: project.environmentId,
83 projectId: project.id,
84}));
85
86describe("scheduled task settings scope", () => {
87 it.each<{ search: SettingsScopeSearch; expected: string[] }>([
88 { search: {}, expected: ["task-0", "task-1", "task-2", "task-3", "task-4"] },
89 { search: { machine: laptopId }, expected: ["task-0", "task-1"] },
90 { search: { project: "t3code" }, expected: ["task-0", "task-1", "task-2"] },
91 { search: { project: "t3code", machine: serverId }, expected: ["task-2"] },
92 { search: { project: "t3code", checkout: second.physicalProjectKey }, expected: ["task-1"] },
93 { search: { project: "missing" }, expected: [] },
94 { search: { machine: "removed" }, expected: [] },
95 { search: { project: "t3code", checkout: "removed" }, expected: [] },
96 { search: { project: "other", machine: laptopId }, expected: [] },
97 ])("lists only matching tasks for $search", ({ search, expected }) => {
98 const scope = resolveSettingsScope(search, groups, environments);
99 expect(
100 tasks
101 .filter((task) => matchesScheduledTaskScope(scope, task.environmentId, task.projectId))
102 .map((task) => task.id),
103 ).toEqual(expected);
104 });
105
106 it("keeps tasks with removed projects manageable at environment scope", () => {
107 const removedProject = ProjectId.make("removed");
108 expect(
109 matchesScheduledTaskScope(
110 resolveSettingsScope({}, groups, environments),
111 laptopId,
112 removedProject,
113 ),
114 ).toBe(true);
115 expect(
116 matchesScheduledTaskScope(
117 resolveSettingsScope({ project: "t3code" }, groups, environments),
118 laptopId,
119 removedProject,
120 ),
121 ).toBe(false);
122 });
123
124 it("does not offer an unrelated environment's same-ID project when creating a task", () => {
125 const scope = resolveSettingsScope({ project: "t3code" }, groups, environments);
126 const serverProjects = [third, other, sameIdElsewhere];
127 expect(
128 serverProjects.filter((project) => matchesScheduledTaskScope(scope, serverId, project.id)),
129 ).toEqual([third]);
130 });
131});
132
133const legacyTask: ScheduledTask = {
134 id: ScheduledTaskId.make("legacy-task"),
135 title: "Review issues",
136 prompt: "Review open issues",
137 enabled: true,
138 schedule: { type: "interval", everyMs: 60_000 },
139 projectId: ProjectId.make("project"),
140 threadId: null,
141 workspaceStrategy: { type: "worktree", baseRef: "release" },
142 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
143 runtimeMode: "full-access",
144 interactionMode: "default",
145 createdBy: "user",
146 creationSource: "web",
147 createdAt: "2026-09-17T00:00:00.000Z",
148 updatedAt: "2026-09-17T00:00:00.000Z",
149 nextRunAt: null,
150 lastRunAt: null,
151 lastRunStatus: "never",
152 lastRunError: null,
153 runCount: 0,
154};
155
156describe("editing scheduled task branch settings", () => {
157 it("keeps an omitted origin flag on the local base branch", () => {
158 const draft = taskToDraft(legacyTask);
159 expect(draft.baseRef).toBe("release");
160 expect(draft.startFromOrigin).toBe(false);
161 });
162
163 it.each([true, false])("preserves an explicit origin flag of %s", (startFromOrigin) => {
164 const draft = taskToDraft({
165 ...legacyTask,
166 workspaceStrategy: { type: "worktree", baseRef: "release", startFromOrigin },
167 });
168 expect(draft.startFromOrigin).toBe(startFromOrigin);
169 });
170});
171
172describe("webhook scheduled tasks", () => {
173 const signature = { header: "x-signature", encoding: "base64", prefix: "" } as const;
174 const webhookTask: ScheduledTask = {
175 ...legacyTask,
176 schedule: { type: "webhook", signature },
177 webhook: { path: "/api/hooks/legacy-task/token", url: null, hasSecret: true },
178 };
179
180 it("keeps a stored secret when the secret field is left blank", () => {
181 const draft = taskToDraft(webhookTask);
182 expect(draft.scheduleMode).toBe("webhook");
183 expect(draft.signatureSecret).toBe("");
184 expect(scheduleFromDraft(draft)).toEqual({
185 type: "webhook",
186 signature,
187 maxDeliveryAgeMinutes: null,
188 });
189 expect(scheduleFromDraft({ ...draft, signatureSecret: " new " })).toEqual({
190 type: "webhook",
191 signature: { ...signature, secret: "new" },
192 maxDeliveryAgeMinutes: null,
193 });
194 });
195
196 it("drops the signature when it is switched off and offers GitHub's settings", () => {
197 const draft = taskToDraft({ ...webhookTask, schedule: { type: "webhook", signature: null } });
198 expect(draft.signatureEnabled).toBe(false);
199 expect(draft.signatureHeader).toBe("x-hub-signature-256");
200 expect(scheduleFromDraft(draft)).toEqual({
201 type: "webhook",
202 signature: null,
203 maxDeliveryAgeMinutes: null,
204 });
205 });
206
207 it("round-trips the max age, treats blank as no limit, and rejects an invalid limit", () => {
208 const draft = taskToDraft({
209 ...webhookTask,
210 schedule: { type: "webhook", signature: null, maxDeliveryAgeMinutes: 90 },
211 });
212 expect(draft.maxDeliveryAgeMinutes).toBe("90");
213 expect(scheduleFromDraft(draft)).toMatchObject({ maxDeliveryAgeMinutes: 90 });
214 for (const blank of ["", " "]) {
215 expect(scheduleFromDraft({ ...draft, maxDeliveryAgeMinutes: blank })).toMatchObject({
216 maxDeliveryAgeMinutes: null,
217 });
218 }
219 for (const invalid of ["0", "-5", "1.5", "abc", "1441"]) {
220 expect(scheduleFromDraft({ ...draft, maxDeliveryAgeMinutes: invalid })).toBeNull();
221 }
222 });
223});
224
225describe("scheduled task model defaults", () => {
226 const instanceId = ProviderInstanceId.make("codex");
227 const projectId = ProjectId.make("project");
228 const environmentSelection = {
229 instanceId,
230 model: "environment-model",
231 options: [{ id: "reasoning", value: "high" }],
232 };
233 const projectSelection = { instanceId, model: "project-model" };
234 const config = {
235 settings: { ...DEFAULT_SERVER_SETTINGS, defaultModelSelection: environmentSelection },
236 providers: [
237 {
238 instanceId,
239 driver: "codex",
240 displayName: "Codex",
241 enabled: true,
242 installed: true,
243 status: "ready",
244 auth: { status: "authenticated" },
245 models: [
246 { slug: "first-model", name: "First", isCustom: false, capabilities: null },
247 {
248 slug: "catalog-default",
249 name: "Default",
250 isDefault: true,
251 isCustom: false,
252 capabilities: null,
253 },
254 { slug: "environment-model", name: "Environment", isCustom: false, capabilities: null },
255 { slug: "project-model", name: "Project", isCustom: false, capabilities: null },
256 ],
257 },
258 ],
259 } as unknown as ServerConfig;
260 const resolve = (
261 value: ServerConfig,
262 project: { id: typeof projectId; defaultModelSelection?: typeof projectSelection } | null,
263 ) =>
264 scheduledTaskDefaultModel(
265 value.settings,
266 project,
267 deriveProviderInstanceEntries(value.providers),
268 );
269 it("uses the environment default with its provider options", () => {
270 expect(resolve(config, { id: projectId })).toEqual(environmentSelection);
271 });
272 it("prefers the project's configured model", () => {
273 expect(resolve(config, { id: projectId, defaultModelSelection: projectSelection })).toEqual(
274 projectSelection,
275 );
276 expect(
277 resolve(
278 {
279 ...config,
280 settings: {
281 ...config.settings,
282 projectSettingsOverrides: {
283 [projectId]: { defaultModelSelection: projectSelection },
284 },
285 },
286 },
287 { id: projectId },
288 ),
289 ).toEqual(projectSelection);
290 });
291 it("uses the advertised default instead of catalog order when no default is configured", () => {
292 expect(
293 resolve({ ...config, settings: { ...config.settings, defaultModelSelection: null } }, null),
294 ).toEqual({ instanceId, model: "catalog-default" });
295 });
296 it("falls back to the environment default when the project provider is unavailable", () => {
297 expect(
298 resolve(config, {
299 id: projectId,
300 defaultModelSelection: {
301 instanceId: ProviderInstanceId.make("unavailable"),
302 model: "missing",
303 },
304 }),
305 ).toEqual(environmentSelection);
306 });
307 it("does not choose an implicit model on a disabled provider", () => {
308 expect(
309 resolve(
310 {
311 ...config,
312 providers: config.providers.map((provider) => ({ ...provider, enabled: false })),
313 },
314 null,
315 ),
316 ).toBeNull();
317 });
318});