Open History to see the last commit
1import { RegistryContext } from "@effect/atom-react";
2import { Atom, AtomRegistry } from "effect/reactivity";
3import {
4 DEFAULT_UNIFIED_SETTINGS,
5 EnvironmentId,
6 ProjectId,
7 ProviderInstanceId,
8 ScheduledTaskId,
9 ThreadId,
10 type ScheduledTask,
11} from "@t3tools/contracts";
12import { describe, expect, it, vi } from "vite-plus/test";
13import { page, userEvent } from "vite-plus/test/browser";
14
15import "~/index.css";
16import { act, render } from "../../../test/render";
17import { ScheduledTasksSettings } from "./ScheduledTasksSettings";
18
19const environmentId = EnvironmentId.make("laptop");
20const projectId = ProjectId.make("project");
21const task: ScheduledTask = {
22 id: ScheduledTaskId.make("nightly"),
23 title: "Review issues",
24 prompt: "Review open issues",
25 enabled: true,
26 schedule: { type: "interval", everyMs: 60_000 },
27 projectId,
28 threadId: ThreadId.make("older-thread"),
29 workspaceStrategy: { type: "root" },
30 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
31 runtimeMode: "full-access",
32 interactionMode: "default",
33 createdBy: "user",
34 creationSource: "web",
35 createdAt: "2026-09-17T00:00:00.000Z",
36 updatedAt: "2026-09-17T00:00:00.000Z",
37 nextRunAt: null,
38 lastRunAt: null,
39 lastRunStatus: "never",
40 lastRunError: null,
41 runCount: 0,
42};
43
44// Stands in for the thread family read the editor makes when the shell lacks the bound thread.
45const threadFamilyRead = {
46 snapshotSequence: 1,
47 threads: [{ id: task.threadId, title: "Fix login bug" }],
48};
49
50const state = vi.hoisted(() => ({ connected: true, tasksLoaded: true, secondary: false }));
51const secondaryId = EnvironmentId.make("secondary");
52const permissions = new Map<string, Atom.Writable<boolean>>();
53
54function setup() {
55 state.connected = true;
56 state.tasksLoaded = true;
57 state.secondary = false;
58 permissions.clear();
59 permissions.set(environmentId, Atom.make(true));
60 permissions.set(secondaryId, Atom.make(true));
61 permissions.set("", Atom.make(false));
62}
63
64function secondaryEnvironment() {
65 return { ...environment(), environmentId: secondaryId, label: "Secondary" };
66}
67
68function environment() {
69 return {
70 environmentId,
71 label: "Laptop",
72 connection: { phase: state.connected ? "connected" : "disconnected" },
73 serverConfig: state.connected
74 ? { environment: { platform: "linux", capabilities: { oneTimeSchedules: true } } }
75 : null,
76 };
77}
78
79vi.mock(import("./SettingsScopeContext"), async (original) => {
80 const scope = () => {
81 const entry = environment();
82 return {
83 scope: { kind: "all", label: "All", members: [], environmentIds: [environmentId] },
84 environments: state.secondary ? [entry, secondaryEnvironment()] : [entry],
85 connectedEnvironments: state.connected
86 ? state.secondary
87 ? [entry, secondaryEnvironment()]
88 : [entry]
89 : [],
90 environment: entry,
91 target: null,
92 targets: [],
93 };
94 };
95 return {
96 ...(await original()),
97 useSettingsScope: scope as never,
98 };
99});
100vi.mock(import("../../state/server"), async (original) => {
101 const actual = await original();
102
103 return {
104 ...actual,
105 serverEnvironment: {
106 ...actual.serverEnvironment,
107 upsertScheduledTask: {
108 ...actual.serverEnvironment.upsertScheduledTask,
109 permissionAtom: (id: string | null) => permissions.get(id ?? "")!,
110 },
111 },
112 };
113});
114vi.mock(import("../../state/threads"), async (original) => {
115 const actual = await original();
116 return {
117 ...actual,
118 threadFamilies: { ...actual.threadFamilies, familyAtom: (() => threadFamilyRead) as never },
119 };
120});
121// Mirrors the real query: a null request (disconnected environment) has no data.
122vi.mock(import("../../state/query"), async (original) => ({
123 ...(await original()),
124 useEnvironmentQuery: ((request: unknown) => ({
125 data:
126 request === null || request === threadFamilyRead
127 ? request
128 : { tasks: state.tasksLoaded ? [task] : [] },
129 error: null,
130 isPending: false,
131 refresh: vi.fn(),
132 })) as never,
133}));
134vi.mock(import("../../state/environments"), async (original) => ({
135 ...(await original()),
136 useEnvironment: ((id: EnvironmentId) =>
137 id === secondaryId ? secondaryEnvironment() : environment()) as never,
138}));
139vi.mock(import("../../state/entities"), async (original) => ({
140 ...(await original()),
141 useProjects: (() => [{ id: projectId, environmentId, title: "Project" }]) as never,
142}));
143vi.mock(import("../../hooks/useSettings"), async (original) => ({
144 ...(await original()),
145 useEnvironmentSettings: (() => DEFAULT_UNIFIED_SETTINGS) as never,
146}));
147
148describe("ScheduledTasksSettings", () => {
149 it("creates on a permitted secondary and reacts to permission changes", async () => {
150 setup();
151 state.secondary = true;
152 const registry = AtomRegistry.make();
153 using _registry = { [Symbol.dispose]: () => registry.dispose() };
154 registry.set(permissions.get(environmentId)!, false);
155 await using screen = await render(
156 <RegistryContext.Provider value={registry}>
157 <ScheduledTasksSettings />
158 </RegistryContext.Provider>,
159 );
160 const create = screen.getByRole("button", { name: "New task" });
161 await expect.element(create).toBeEnabled();
162 await act(() => registry.set(permissions.get(secondaryId)!, false));
163 await expect.element(create).toBeDisabled();
164 await act(() => registry.set(permissions.get(secondaryId)!, true));
165 await expect.element(create).toBeEnabled();
166 await create.click();
167 await expect.element(page.getByLabelText("Runs on")).toHaveTextContent("Secondary");
168 });
169
170 it("keeps the permitted representative as the creation target", async () => {
171 setup();
172 state.secondary = true;
173 await using screen = await render(<ScheduledTasksSettings />);
174 await screen.getByRole("button", { name: "New task" }).click();
175 await expect.element(page.getByLabelText("Runs on")).toHaveTextContent("Laptop");
176 });
177
178 it("disables creation without a connected target", async () => {
179 setup();
180 state.connected = false;
181 await using screen = await render(<ScheduledTasksSettings />);
182 await expect.element(screen.getByRole("button", { name: "New task" })).toBeDisabled();
183 });
184
185 it("keeps a linked task's unsaved edits across a disconnect and reconnect", async () => {
186 setup();
187 await using screen = await render(<ScheduledTasksSettings taskId={task.id} />);
188 await expect.element(page.getByRole("dialog").getByText("Fix login bug")).toBeVisible();
189 const name = page.getByRole("dialog").getByRole("textbox", { name: "Name" });
190 await expect.element(name).toHaveValue("Review issues");
191 await userEvent.fill(name, "Review stale issues");
192
193 state.connected = false;
194 await screen.rerender(<ScheduledTasksSettings taskId={task.id} />);
195
196 await expect.element(name).toHaveValue("Review stale issues");
197
198 state.connected = true;
199 await screen.rerender(<ScheduledTasksSettings taskId={task.id} />);
200 await expect.element(name).toHaveValue("Review stale issues");
201 });
202
203 it("keeps an editor the user opened when the linked task loads later", async () => {
204 setup();
205 state.tasksLoaded = false;
206 await using screen = await render(<ScheduledTasksSettings taskId={task.id} />);
207 await userEvent.click(page.getByRole("button", { name: "New task" }));
208 const dialog = page.getByRole("dialog");
209 const name = dialog.getByRole("textbox", { name: "Name" });
210 await userEvent.fill(name, "My draft");
211
212 state.tasksLoaded = true;
213 await screen.rerender(<ScheduledTasksSettings taskId={task.id} />);
214
215 await expect.element(dialog.getByRole("heading", { name: "New task" })).toBeInTheDocument();
216 await expect.element(name).toHaveValue("My draft");
217 });
218});