@stigmer/react 3.5.2 → 3.6.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.
Files changed (193) hide show
  1. package/agent/AgentPicker.d.ts +10 -1
  2. package/agent/AgentPicker.d.ts.map +1 -1
  3. package/agent/AgentPicker.js +2 -2
  4. package/agent/AgentPicker.js.map +1 -1
  5. package/channel/AgentChannelsPanel.d.ts.map +1 -1
  6. package/channel/AgentChannelsPanel.js +22 -5
  7. package/channel/AgentChannelsPanel.js.map +1 -1
  8. package/channel/ChannelTemplatesDialog.d.ts +53 -0
  9. package/channel/ChannelTemplatesDialog.d.ts.map +1 -0
  10. package/channel/ChannelTemplatesDialog.js +150 -0
  11. package/channel/ChannelTemplatesDialog.js.map +1 -0
  12. package/channel/index.d.ts +8 -0
  13. package/channel/index.d.ts.map +1 -1
  14. package/channel/index.js +4 -0
  15. package/channel/index.js.map +1 -1
  16. package/channel/providers.d.ts +12 -0
  17. package/channel/providers.d.ts.map +1 -1
  18. package/channel/providers.js +14 -2
  19. package/channel/providers.js.map +1 -1
  20. package/channel/templatePresentation.d.ts +37 -0
  21. package/channel/templatePresentation.d.ts.map +1 -0
  22. package/channel/templatePresentation.js +69 -0
  23. package/channel/templatePresentation.js.map +1 -0
  24. package/channel/useChannelTemplateList.d.ts +64 -0
  25. package/channel/useChannelTemplateList.d.ts.map +1 -0
  26. package/channel/useChannelTemplateList.js +69 -0
  27. package/channel/useChannelTemplateList.js.map +1 -0
  28. package/channel/useChannelTemplateReadiness.d.ts +53 -0
  29. package/channel/useChannelTemplateReadiness.d.ts.map +1 -0
  30. package/channel/useChannelTemplateReadiness.js +42 -0
  31. package/channel/useChannelTemplateReadiness.js.map +1 -0
  32. package/dashboard/useDashboardFailedRuns.d.ts.map +1 -1
  33. package/dashboard/useDashboardFailedRuns.js +2 -0
  34. package/dashboard/useDashboardFailedRuns.js.map +1 -1
  35. package/index.d.ts +4 -2
  36. package/index.d.ts.map +1 -1
  37. package/index.js +9 -1
  38. package/index.js.map +1 -1
  39. package/library/useExportResource.d.ts +4 -3
  40. package/library/useExportResource.d.ts.map +1 -1
  41. package/library/useExportResource.js +1 -1
  42. package/library/useExportResource.js.map +1 -1
  43. package/organization/OrgProvider.d.ts +11 -4
  44. package/organization/OrgProvider.d.ts.map +1 -1
  45. package/organization/OrgProvider.js +28 -4
  46. package/organization/OrgProvider.js.map +1 -1
  47. package/package.json +4 -4
  48. package/resource-detail/useDeleteResource.d.ts +1 -1
  49. package/resource-detail/useDeleteResource.d.ts.map +1 -1
  50. package/resource-detail/useDeleteResource.js +5 -0
  51. package/resource-detail/useDeleteResource.js.map +1 -1
  52. package/resource-workbench/components/ResourceWorkbench.d.ts +10 -1
  53. package/resource-workbench/components/ResourceWorkbench.d.ts.map +1 -1
  54. package/resource-workbench/components/ResourceWorkbench.js +6 -2
  55. package/resource-workbench/components/ResourceWorkbench.js.map +1 -1
  56. package/resource-workbench/hooks/useResourceCollection.d.ts +8 -0
  57. package/resource-workbench/hooks/useResourceCollection.d.ts.map +1 -1
  58. package/resource-workbench/hooks/useResourceCollection.js +2 -2
  59. package/resource-workbench/hooks/useResourceCollection.js.map +1 -1
  60. package/schedule/CadenceField.d.ts +31 -0
  61. package/schedule/CadenceField.d.ts.map +1 -0
  62. package/schedule/CadenceField.js +116 -0
  63. package/schedule/CadenceField.js.map +1 -0
  64. package/schedule/ScheduleDetailView.d.ts +58 -0
  65. package/schedule/ScheduleDetailView.d.ts.map +1 -0
  66. package/schedule/ScheduleDetailView.js +236 -0
  67. package/schedule/ScheduleDetailView.js.map +1 -0
  68. package/schedule/ScheduleForm.d.ts +43 -0
  69. package/schedule/ScheduleForm.d.ts.map +1 -0
  70. package/schedule/ScheduleForm.js +129 -0
  71. package/schedule/ScheduleForm.js.map +1 -0
  72. package/schedule/ScheduleRowActions.d.ts +20 -0
  73. package/schedule/ScheduleRowActions.d.ts.map +1 -0
  74. package/schedule/ScheduleRowActions.js +54 -0
  75. package/schedule/ScheduleRowActions.js.map +1 -0
  76. package/schedule/TimeZoneField.d.ts +32 -0
  77. package/schedule/TimeZoneField.d.ts.map +1 -0
  78. package/schedule/TimeZoneField.js +104 -0
  79. package/schedule/TimeZoneField.js.map +1 -0
  80. package/schedule/cadence.d.ts +95 -0
  81. package/schedule/cadence.d.ts.map +1 -0
  82. package/schedule/cadence.js +242 -0
  83. package/schedule/cadence.js.map +1 -0
  84. package/schedule/index.d.ts +31 -0
  85. package/schedule/index.d.ts.map +1 -0
  86. package/schedule/index.js +16 -0
  87. package/schedule/index.js.map +1 -0
  88. package/schedule/scheduleColumns.d.ts +26 -0
  89. package/schedule/scheduleColumns.d.ts.map +1 -0
  90. package/schedule/scheduleColumns.js +77 -0
  91. package/schedule/scheduleColumns.js.map +1 -0
  92. package/schedule/scheduleListFn.d.ts +50 -0
  93. package/schedule/scheduleListFn.d.ts.map +1 -0
  94. package/schedule/scheduleListFn.js +65 -0
  95. package/schedule/scheduleListFn.js.map +1 -0
  96. package/schedule/scheduleState.d.ts +43 -0
  97. package/schedule/scheduleState.d.ts.map +1 -0
  98. package/schedule/scheduleState.js +67 -0
  99. package/schedule/scheduleState.js.map +1 -0
  100. package/schedule/useCreateSchedule.d.ts +57 -0
  101. package/schedule/useCreateSchedule.d.ts.map +1 -0
  102. package/schedule/useCreateSchedule.js +61 -0
  103. package/schedule/useCreateSchedule.js.map +1 -0
  104. package/schedule/useResumeSchedule.d.ts +30 -0
  105. package/schedule/useResumeSchedule.d.ts.map +1 -0
  106. package/schedule/useResumeSchedule.js +45 -0
  107. package/schedule/useResumeSchedule.js.map +1 -0
  108. package/schedule/useSchedule.d.ts +39 -0
  109. package/schedule/useSchedule.d.ts.map +1 -0
  110. package/schedule/useSchedule.js +44 -0
  111. package/schedule/useSchedule.js.map +1 -0
  112. package/schedule/useScheduleCount.d.ts +38 -0
  113. package/schedule/useScheduleCount.d.ts.map +1 -0
  114. package/schedule/useScheduleCount.js +30 -0
  115. package/schedule/useScheduleCount.js.map +1 -0
  116. package/schedule/useScheduleList.d.ts +48 -0
  117. package/schedule/useScheduleList.d.ts.map +1 -0
  118. package/schedule/useScheduleList.js +43 -0
  119. package/schedule/useScheduleList.js.map +1 -0
  120. package/schedule/useSetScheduleEnabled.d.ts +31 -0
  121. package/schedule/useSetScheduleEnabled.d.ts.map +1 -0
  122. package/schedule/useSetScheduleEnabled.js +53 -0
  123. package/schedule/useSetScheduleEnabled.js.map +1 -0
  124. package/schedule/useTriggerSchedule.d.ts +28 -0
  125. package/schedule/useTriggerSchedule.d.ts.map +1 -0
  126. package/schedule/useTriggerSchedule.js +43 -0
  127. package/schedule/useTriggerSchedule.js.map +1 -0
  128. package/search/useResourceCount.d.ts +2 -1
  129. package/search/useResourceCount.d.ts.map +1 -1
  130. package/search/useResourceCount.js +6 -1
  131. package/search/useResourceCount.js.map +1 -1
  132. package/src/agent/AgentPicker.tsx +13 -1
  133. package/src/channel/AgentChannelsPanel.tsx +43 -1
  134. package/src/channel/ChannelTemplatesDialog.tsx +433 -0
  135. package/src/channel/__tests__/AgentChannelsPanel.test.tsx +73 -0
  136. package/src/channel/__tests__/ChannelTemplatesDialog.test.tsx +292 -0
  137. package/src/channel/__tests__/providers.test.ts +9 -0
  138. package/src/channel/__tests__/templatePresentation.test.ts +96 -0
  139. package/src/channel/__tests__/useChannelTemplateList.test.tsx +216 -0
  140. package/src/channel/__tests__/useChannelTemplateReadiness.test.tsx +124 -0
  141. package/src/channel/index.ts +15 -0
  142. package/src/channel/providers.ts +26 -2
  143. package/src/channel/templatePresentation.ts +82 -0
  144. package/src/channel/useChannelTemplateList.ts +123 -0
  145. package/src/channel/useChannelTemplateReadiness.ts +69 -0
  146. package/src/dashboard/__tests__/useDashboardFailedRuns.test.tsx +106 -0
  147. package/src/dashboard/useDashboardFailedRuns.ts +2 -0
  148. package/src/execution/__tests__/PlanArtifactCard.test.tsx +23 -2
  149. package/src/index.ts +65 -0
  150. package/src/library/useExportResource.ts +4 -3
  151. package/src/organization/OrgProvider.tsx +29 -4
  152. package/src/organization/__tests__/OrgProvider.orgSwitch.test.tsx +89 -0
  153. package/src/resource-detail/useDeleteResource.ts +11 -1
  154. package/src/resource-workbench/__tests__/ResourceWorkbench.test.tsx +112 -0
  155. package/src/resource-workbench/components/ResourceWorkbench.tsx +32 -15
  156. package/src/resource-workbench/hooks/useResourceCollection.ts +11 -1
  157. package/src/schedule/CadenceField.tsx +365 -0
  158. package/src/schedule/ScheduleDetailView.tsx +583 -0
  159. package/src/schedule/ScheduleForm.tsx +405 -0
  160. package/src/schedule/ScheduleRowActions.tsx +124 -0
  161. package/src/schedule/TimeZoneField.tsx +199 -0
  162. package/src/schedule/__tests__/ScheduleDetailView.test.tsx +260 -0
  163. package/src/schedule/__tests__/cadence.test.ts +278 -0
  164. package/src/schedule/__tests__/scheduleColumnsRowActions.test.tsx +176 -0
  165. package/src/schedule/__tests__/scheduleCreation.test.tsx +340 -0
  166. package/src/schedule/__tests__/scheduleHooks.test.tsx +312 -0
  167. package/src/schedule/__tests__/scheduleListFn.test.ts +128 -0
  168. package/src/schedule/__tests__/scheduleState.test.ts +133 -0
  169. package/src/schedule/cadence.ts +298 -0
  170. package/src/schedule/index.ts +57 -0
  171. package/src/schedule/scheduleColumns.tsx +118 -0
  172. package/src/schedule/scheduleListFn.ts +108 -0
  173. package/src/schedule/scheduleState.ts +96 -0
  174. package/src/schedule/useCreateSchedule.ts +88 -0
  175. package/src/schedule/useResumeSchedule.ts +67 -0
  176. package/src/schedule/useSchedule.ts +67 -0
  177. package/src/schedule/useScheduleCount.ts +57 -0
  178. package/src/schedule/useScheduleList.ts +83 -0
  179. package/src/schedule/useSetScheduleEnabled.ts +84 -0
  180. package/src/schedule/useTriggerSchedule.ts +65 -0
  181. package/src/search/__tests__/useResourceCount.test.tsx +89 -0
  182. package/src/search/useResourceCount.ts +8 -2
  183. package/src/workflow/WorkflowDashboard.tsx +1 -0
  184. package/src/workflow/useWorkflowExecutionList.ts +13 -2
  185. package/styles.css +1 -1
  186. package/workflow/CanvasTransitionEdge.d.ts +1 -1
  187. package/workflow/WorkflowDashboard.d.ts.map +1 -1
  188. package/workflow/WorkflowDashboard.js +1 -0
  189. package/workflow/WorkflowDashboard.js.map +1 -1
  190. package/workflow/useWorkflowExecutionList.d.ts +10 -0
  191. package/workflow/useWorkflowExecutionList.d.ts.map +1 -1
  192. package/workflow/useWorkflowExecutionList.js +3 -2
  193. package/workflow/useWorkflowExecutionList.js.map +1 -1
@@ -0,0 +1,340 @@
1
+ import { describe, it, expect, vi, beforeAll, afterEach, beforeEach } from "vitest";
2
+ import {
3
+ render,
4
+ screen,
5
+ cleanup,
6
+ fireEvent,
7
+ waitFor,
8
+ } from "@testing-library/react";
9
+ import { renderHook, act } from "@testing-library/react";
10
+ import { useState, type ReactNode } from "react";
11
+ import { create } from "@bufbuild/protobuf";
12
+ import { ScheduleSchema } from "@stigmer/protos/ai/stigmer/agentic/schedule/v1/api_pb";
13
+ import { StigmerContext } from "../../context";
14
+ import { FetchCacheContext } from "../../internal/FetchCacheProvider";
15
+ import { useCreateSchedule } from "../useCreateSchedule";
16
+ import { CadenceField } from "../CadenceField";
17
+ import { ScheduleForm } from "../ScheduleForm";
18
+ import type { CadencePreset } from "../cadence";
19
+
20
+ // Without a StigmerProvider the portal container is null, and Base UI's
21
+ // Portal renders nothing — pin it to document.body so the agent-picker
22
+ // popover mounts.
23
+ vi.mock("../../portal-container", () => ({
24
+ useStigmerPortalContainer: () => document.body,
25
+ }));
26
+
27
+ // Base UI's Popover positioner observes its anchor; happy-dom lacks
28
+ // ResizeObserver, so provide a no-op shim.
29
+ beforeAll(() => {
30
+ if (!("ResizeObserver" in globalThis)) {
31
+ (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver =
32
+ class {
33
+ observe() {}
34
+ unobserve() {}
35
+ disconnect() {}
36
+ };
37
+ }
38
+ });
39
+
40
+ function wrapper(client: unknown) {
41
+ return function Wrapper({ children }: { children: ReactNode }) {
42
+ return (
43
+ <FetchCacheContext.Provider value={null}>
44
+ <StigmerContext.Provider value={client as never}>
45
+ {children}
46
+ </StigmerContext.Provider>
47
+ </FetchCacheContext.Provider>
48
+ );
49
+ };
50
+ }
51
+
52
+ const CREATED = create(ScheduleSchema, {
53
+ metadata: {
54
+ id: "sch_01new",
55
+ name: "daily-fee-reminders",
56
+ slug: "daily-fee-reminders",
57
+ org: "isc",
58
+ },
59
+ spec: { cron: "0 9 * * *", timeZone: "Asia/Kolkata", enabled: false },
60
+ });
61
+
62
+ const AGENT_RESULT = {
63
+ id: "agt_01example",
64
+ org: "isc",
65
+ slug: "fee-reminder",
66
+ name: "Fee Reminder",
67
+ description: "Sends fee reminders.",
68
+ };
69
+
70
+ beforeEach(() => vi.clearAllMocks());
71
+ afterEach(cleanup);
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // useCreateSchedule
75
+ // ---------------------------------------------------------------------------
76
+
77
+ describe("useCreateSchedule", () => {
78
+ it("applies the input and returns the created schedule", async () => {
79
+ const apply = vi.fn().mockResolvedValue(CREATED);
80
+ const client = { schedule: { apply } };
81
+
82
+ const { result } = renderHook(() => useCreateSchedule(), {
83
+ wrapper: wrapper(client),
84
+ });
85
+
86
+ const input = {
87
+ name: "daily-fee-reminders",
88
+ org: "isc",
89
+ cron: "0 9 * * *",
90
+ timeZone: "Asia/Kolkata",
91
+ enabled: false,
92
+ agent: {
93
+ agentRef: { org: "isc", slug: "fee-reminder" },
94
+ message: "Send today's reminders.",
95
+ },
96
+ };
97
+
98
+ let returned: unknown;
99
+ await act(async () => {
100
+ returned = await result.current.create(input);
101
+ });
102
+
103
+ expect(apply).toHaveBeenCalledWith(input);
104
+ expect(returned).toBe(CREATED);
105
+ expect(result.current.isCreating).toBe(false);
106
+ expect(result.current.error).toBeNull();
107
+ });
108
+
109
+ it("captures the error, rethrows, and clears via clearError", async () => {
110
+ const failure = new Error("agent 'isc/fee-reminder' not found");
111
+ const apply = vi.fn().mockRejectedValue(failure);
112
+ const client = { schedule: { apply } };
113
+
114
+ const { result } = renderHook(() => useCreateSchedule(), {
115
+ wrapper: wrapper(client),
116
+ });
117
+
118
+ await act(async () => {
119
+ await expect(
120
+ result.current.create({ name: "x", org: "isc" }),
121
+ ).rejects.toThrow(failure);
122
+ });
123
+
124
+ expect(result.current.error).toBe(failure);
125
+
126
+ act(() => result.current.clearError());
127
+ expect(result.current.error).toBeNull();
128
+ });
129
+ });
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // CadenceField
133
+ // ---------------------------------------------------------------------------
134
+
135
+ function ControlledCadenceField({ initial }: { initial: CadencePreset }) {
136
+ const [value, setValue] = useState<CadencePreset>(initial);
137
+ return <CadenceField value={value} onChange={setValue} timeZone="UTC" />;
138
+ }
139
+
140
+ describe("CadenceField", () => {
141
+ it("summarizes the selected preset in plain English", () => {
142
+ render(
143
+ <ControlledCadenceField initial={{ kind: "daily", hour: 9, minute: 0 }} />,
144
+ );
145
+ expect(screen.getByTestId("cadence-summary").textContent).toBe(
146
+ "Every day at 09:00 (UTC)",
147
+ );
148
+ });
149
+
150
+ it("carries the time of day across preset switches", () => {
151
+ render(
152
+ <ControlledCadenceField initial={{ kind: "daily", hour: 9, minute: 30 }} />,
153
+ );
154
+ fireEvent.click(screen.getByRole("radio", { name: "Weekly" }));
155
+ expect(screen.getByTestId("cadence-summary").textContent).toBe(
156
+ "Every Monday at 09:30 (UTC)",
157
+ );
158
+ });
159
+
160
+ it("prefills the custom escape hatch with the generated cron", () => {
161
+ render(
162
+ <ControlledCadenceField initial={{ kind: "daily", hour: 9, minute: 0 }} />,
163
+ );
164
+ fireEvent.click(screen.getByRole("radio", { name: "Custom cron" }));
165
+ expect(
166
+ (screen.getByRole("textbox", { name: "Cron expression" }) as HTMLInputElement)
167
+ .value,
168
+ ).toBe("0 9 * * *");
169
+ });
170
+
171
+ it("rejects invalid custom cron with the server's wording", () => {
172
+ render(
173
+ <ControlledCadenceField initial={{ kind: "custom", cron: "@every 30s" }} />,
174
+ );
175
+ expect(screen.getByRole("alert").textContent).toBe(
176
+ "spec.cron must be a calendar expression — @every intervals are not supported",
177
+ );
178
+ expect(screen.queryByTestId("cadence-summary")).toBeNull();
179
+ });
180
+
181
+ it("keeps the last selected weekly day locked", () => {
182
+ render(
183
+ <ControlledCadenceField
184
+ initial={{ kind: "weekly", days: [1], hour: 9, minute: 0 }}
185
+ />,
186
+ );
187
+ const monday = screen.getByRole("button", { name: "Monday" });
188
+ expect((monday as HTMLButtonElement).disabled).toBe(true);
189
+
190
+ fireEvent.click(screen.getByRole("button", { name: "Friday" }));
191
+ expect(
192
+ (screen.getByRole("button", { name: "Monday" }) as HTMLButtonElement)
193
+ .disabled,
194
+ ).toBe(false);
195
+ });
196
+
197
+ it("warns that days 29-31 skip shorter months", () => {
198
+ render(
199
+ <ControlledCadenceField
200
+ initial={{ kind: "monthly", day: 31, hour: 9, minute: 0 }}
201
+ />,
202
+ );
203
+ expect(screen.getByText(/Months without day 31 are skipped/)).toBeTruthy();
204
+ });
205
+ });
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // ScheduleForm
209
+ // ---------------------------------------------------------------------------
210
+
211
+ function createClient(overrides?: {
212
+ apply?: ReturnType<typeof vi.fn>;
213
+ list?: ReturnType<typeof vi.fn>;
214
+ }) {
215
+ return {
216
+ schedule: { apply: overrides?.apply ?? vi.fn().mockResolvedValue(CREATED) },
217
+ agent: {
218
+ list:
219
+ overrides?.list ?? vi.fn().mockResolvedValue({ entries: [AGENT_RESULT] }),
220
+ },
221
+ };
222
+ }
223
+
224
+ async function pickAgent() {
225
+ fireEvent.click(screen.getByText("Choose an agent…"));
226
+ const result = await screen.findByRole("option", { name: /Fee Reminder/ });
227
+ fireEvent.click(result);
228
+ }
229
+
230
+ describe("ScheduleForm", () => {
231
+ it("disables submit until name, agent, and message are provided", async () => {
232
+ const client = createClient();
233
+ render(<ScheduleForm org="isc" />, { wrapper: wrapper(client) });
234
+
235
+ const submit = screen.getByRole("button", { name: "Create schedule" });
236
+ expect((submit as HTMLButtonElement).disabled).toBe(true);
237
+
238
+ fireEvent.change(screen.getByLabelText("Name"), {
239
+ target: { value: "daily-fee-reminders" },
240
+ });
241
+ fireEvent.change(screen.getByLabelText("Message"), {
242
+ target: { value: "Send today's reminders." },
243
+ });
244
+ expect((submit as HTMLButtonElement).disabled).toBe(true);
245
+
246
+ await pickAgent();
247
+ expect((submit as HTMLButtonElement).disabled).toBe(false);
248
+ });
249
+
250
+ it("locks the agent picker to org scope (no Org/All toggle)", async () => {
251
+ const client = createClient();
252
+ render(<ScheduleForm org="isc" />, { wrapper: wrapper(client) });
253
+
254
+ fireEvent.click(screen.getByText("Choose an agent…"));
255
+ await screen.findByRole("option", { name: /Fee Reminder/ });
256
+
257
+ expect(
258
+ screen.queryByRole("radiogroup", { name: "Resource scope" }),
259
+ ).toBeNull();
260
+ });
261
+
262
+ it("submits the generated cron, browser time zone, and staged-disabled default", async () => {
263
+ const apply = vi.fn().mockResolvedValue(CREATED);
264
+ const client = createClient({ apply });
265
+ const onComplete = vi.fn();
266
+
267
+ render(<ScheduleForm org="isc" onComplete={onComplete} />, {
268
+ wrapper: wrapper(client),
269
+ });
270
+
271
+ fireEvent.change(screen.getByLabelText("Name"), {
272
+ target: { value: "daily-fee-reminders" },
273
+ });
274
+ fireEvent.change(screen.getByLabelText("Message"), {
275
+ target: { value: " Send today's reminders. " },
276
+ });
277
+ await pickAgent();
278
+
279
+ fireEvent.click(screen.getByRole("button", { name: "Create schedule" }));
280
+
281
+ await waitFor(() => expect(onComplete).toHaveBeenCalledWith(CREATED));
282
+ expect(apply).toHaveBeenCalledWith({
283
+ name: "daily-fee-reminders",
284
+ org: "isc",
285
+ // The default cadence: daily at 09:00.
286
+ cron: "0 9 * * *",
287
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
288
+ // Staged-disabled by default — validate with Run now, then enable.
289
+ enabled: false,
290
+ agent: {
291
+ agentRef: { org: "isc", slug: "fee-reminder", kind: 40 },
292
+ message: "Send today's reminders.",
293
+ },
294
+ });
295
+ });
296
+
297
+ it("surfaces the server error verbatim and stays editable", async () => {
298
+ const apply = vi
299
+ .fn()
300
+ .mockRejectedValue(
301
+ new Error(
302
+ "spec.cron must be a calendar expression — @every intervals are not supported",
303
+ ),
304
+ );
305
+ const client = createClient({ apply });
306
+ const onComplete = vi.fn();
307
+
308
+ render(<ScheduleForm org="isc" onComplete={onComplete} />, {
309
+ wrapper: wrapper(client),
310
+ });
311
+
312
+ fireEvent.change(screen.getByLabelText("Name"), {
313
+ target: { value: "daily-fee-reminders" },
314
+ });
315
+ fireEvent.change(screen.getByLabelText("Message"), {
316
+ target: { value: "Send today's reminders." },
317
+ });
318
+ await pickAgent();
319
+ fireEvent.click(screen.getByRole("button", { name: "Create schedule" }));
320
+
321
+ const alert = await screen.findByRole("alert");
322
+ expect(alert.textContent).toContain("@every intervals are not supported");
323
+ expect(onComplete).not.toHaveBeenCalled();
324
+ expect(
325
+ (screen.getByRole("button", { name: "Create schedule" }) as HTMLButtonElement)
326
+ .disabled,
327
+ ).toBe(false);
328
+ });
329
+
330
+ it("fires onCancel", () => {
331
+ const client = createClient();
332
+ const onCancel = vi.fn();
333
+ render(<ScheduleForm org="isc" onCancel={onCancel} />, {
334
+ wrapper: wrapper(client),
335
+ });
336
+
337
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
338
+ expect(onCancel).toHaveBeenCalled();
339
+ });
340
+ });
@@ -0,0 +1,312 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { renderHook, waitFor } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+ import { clone, create, equals } from "@bufbuild/protobuf";
5
+ import { ScheduleSchema, type Schedule } from "@stigmer/protos/ai/stigmer/agentic/schedule/v1/api_pb";
6
+ import { ScheduleListSchema } from "@stigmer/protos/ai/stigmer/agentic/schedule/v1/io_pb";
7
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
8
+ import { StigmerError } from "@stigmer/sdk";
9
+ import { StigmerContext } from "../../context";
10
+ import { FetchCacheContext } from "../../internal/FetchCacheProvider";
11
+ import { useSchedule } from "../useSchedule";
12
+ import { useScheduleList } from "../useScheduleList";
13
+ import { useScheduleCount } from "../useScheduleCount";
14
+ import { useResumeSchedule } from "../useResumeSchedule";
15
+ import { useTriggerSchedule } from "../useTriggerSchedule";
16
+ import { useSetScheduleEnabled } from "../useSetScheduleEnabled";
17
+
18
+ vi.mock("../../feedback/toast", () => ({
19
+ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
20
+ }));
21
+
22
+ import { toast } from "../../feedback/toast";
23
+
24
+ function wrapper(client: unknown) {
25
+ return function Wrapper({ children }: { children: ReactNode }) {
26
+ return (
27
+ <FetchCacheContext.Provider value={null}>
28
+ <StigmerContext.Provider value={client as never}>
29
+ {children}
30
+ </StigmerContext.Provider>
31
+ </FetchCacheContext.Provider>
32
+ );
33
+ };
34
+ }
35
+
36
+ // Shaped like a server-returned schedule, including metadata the curated
37
+ // ScheduleInput cannot express (tags) — the round-trip canary.
38
+ const SCHEDULE = create(ScheduleSchema, {
39
+ apiVersion: "agentic.stigmer.ai/v1",
40
+ kind: "Schedule",
41
+ metadata: {
42
+ id: "sch_01example",
43
+ name: "daily-fee-reminders",
44
+ slug: "daily-fee-reminders",
45
+ org: "isc",
46
+ tags: ["billing"],
47
+ labels: { team: "ops" },
48
+ },
49
+ spec: {
50
+ cron: "0 9 * * *",
51
+ timeZone: "Asia/Kolkata",
52
+ enabled: true,
53
+ target: {
54
+ case: "agent",
55
+ value: {
56
+ agentRef: { kind: ApiResourceKind.agent, org: "isc", slug: "fee-reminder" },
57
+ message: "Send today's fee reminders.",
58
+ },
59
+ },
60
+ },
61
+ status: { consecutiveFailures: 2 },
62
+ });
63
+
64
+ beforeEach(() => vi.clearAllMocks());
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Data hooks
68
+ // ---------------------------------------------------------------------------
69
+
70
+ describe("useSchedule", () => {
71
+ it("fetches a schedule by org/slug reference", async () => {
72
+ const getByReference = vi.fn().mockResolvedValue(SCHEDULE);
73
+ const client = { schedule: { getByReference } };
74
+
75
+ const { result } = renderHook(() => useSchedule("isc", "daily-fee-reminders"), {
76
+ wrapper: wrapper(client),
77
+ });
78
+
79
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
80
+ expect(result.current.schedule).toBe(SCHEDULE);
81
+ expect(result.current.error).toBeNull();
82
+ expect(getByReference).toHaveBeenCalledWith({
83
+ org: "isc",
84
+ slug: "daily-fee-reminders",
85
+ });
86
+ });
87
+
88
+ it("treats NOT_FOUND as null, not an error", async () => {
89
+ // Positional ctor: (code, message, connectCode) — 5 is Connect NotFound.
90
+ const notFound = new StigmerError("not-found", "Schedule 'x' not found", 5);
91
+ const client = {
92
+ schedule: { getByReference: vi.fn().mockRejectedValue(notFound) },
93
+ };
94
+
95
+ const { result } = renderHook(() => useSchedule("isc", "x"), {
96
+ wrapper: wrapper(client),
97
+ });
98
+
99
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
100
+ expect(result.current.schedule).toBeNull();
101
+ expect(result.current.error).toBeNull();
102
+ });
103
+
104
+ it("skips fetching when org or slug is null", () => {
105
+ const getByReference = vi.fn();
106
+ const client = { schedule: { getByReference } };
107
+
108
+ const { result } = renderHook(() => useSchedule(null, "x"), {
109
+ wrapper: wrapper(client),
110
+ });
111
+
112
+ expect(result.current.isLoading).toBe(false);
113
+ expect(getByReference).not.toHaveBeenCalled();
114
+ });
115
+ });
116
+
117
+ describe("useScheduleList", () => {
118
+ it("fetches a page of full Schedule protos", async () => {
119
+ const list = vi.fn().mockResolvedValue(
120
+ create(ScheduleListSchema, { items: [SCHEDULE], totalCount: 1 }),
121
+ );
122
+ const client = { schedule: { list } };
123
+
124
+ const { result } = renderHook(() => useScheduleList("isc"), {
125
+ wrapper: wrapper(client),
126
+ });
127
+
128
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
129
+ expect(result.current.schedules).toHaveLength(1);
130
+ expect(result.current.schedules[0].spec?.cron).toBe("0 9 * * *");
131
+ expect(result.current.totalCount).toBe(1);
132
+ expect(result.current.totalPages).toBe(1);
133
+ });
134
+
135
+ it("skips fetching when org is null", () => {
136
+ const list = vi.fn();
137
+ const client = { schedule: { list } };
138
+
139
+ const { result } = renderHook(() => useScheduleList(null), {
140
+ wrapper: wrapper(client),
141
+ });
142
+
143
+ expect(result.current.schedules).toEqual([]);
144
+ expect(list).not.toHaveBeenCalled();
145
+ });
146
+ });
147
+
148
+ describe("useScheduleCount", () => {
149
+ it("reads only the total count", async () => {
150
+ const list = vi.fn().mockResolvedValue(
151
+ create(ScheduleListSchema, { items: [SCHEDULE], totalCount: 7 }),
152
+ );
153
+ const client = { schedule: { list } };
154
+
155
+ const { result } = renderHook(() => useScheduleCount("isc"), {
156
+ wrapper: wrapper(client),
157
+ });
158
+
159
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
160
+ expect(result.current.count).toBe(7);
161
+ });
162
+ });
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Behavior hooks
166
+ // ---------------------------------------------------------------------------
167
+
168
+ describe("useResumeSchedule", () => {
169
+ it("resumes by id and toasts success", async () => {
170
+ const resume = vi.fn().mockResolvedValue(SCHEDULE);
171
+ const client = { schedule: { resume } };
172
+
173
+ const { result } = renderHook(() => useResumeSchedule(), {
174
+ wrapper: wrapper(client),
175
+ });
176
+
177
+ const resumed = await result.current.resumeSchedule("sch_01example");
178
+
179
+ expect(resumed).toBe(SCHEDULE);
180
+ expect(resume).toHaveBeenCalledWith("sch_01example");
181
+ expect(toast.success).toHaveBeenCalledWith("Schedule resumed");
182
+ });
183
+
184
+ it("relays a failure and sets error state", async () => {
185
+ const failure = new Error("boom");
186
+ const client = { schedule: { resume: vi.fn().mockRejectedValue(failure) } };
187
+
188
+ const { result } = renderHook(() => useResumeSchedule(), {
189
+ wrapper: wrapper(client),
190
+ });
191
+
192
+ await expect(result.current.resumeSchedule("sch_x")).rejects.toThrow("boom");
193
+ await waitFor(() => expect(result.current.error?.message).toBe("boom"));
194
+ expect(toast.error).toHaveBeenCalled();
195
+ });
196
+ });
197
+
198
+ describe("useTriggerSchedule", () => {
199
+ it("triggers by id and toasts success", async () => {
200
+ const trigger = vi.fn().mockResolvedValue(SCHEDULE);
201
+ const client = { schedule: { trigger } };
202
+
203
+ const { result } = renderHook(() => useTriggerSchedule(), {
204
+ wrapper: wrapper(client),
205
+ });
206
+
207
+ await result.current.triggerSchedule("sch_01example");
208
+
209
+ expect(trigger).toHaveBeenCalledWith("sch_01example");
210
+ expect(toast.success).toHaveBeenCalledWith("Run started");
211
+ });
212
+
213
+ it("relays the server's refusal copy verbatim", async () => {
214
+ // The backend's refusal matrix copy is byte-identical across editions
215
+ // and names the remedy — the UI must not paraphrase it.
216
+ const refusal = new StigmerError(
217
+ "failed-precondition",
218
+ "schedule is disabled (spec.enabled=false) — enable it before triggering",
219
+ 9, // Connect FailedPrecondition
220
+ );
221
+ const client = { schedule: { trigger: vi.fn().mockRejectedValue(refusal) } };
222
+
223
+ const { result } = renderHook(() => useTriggerSchedule(), {
224
+ wrapper: wrapper(client),
225
+ });
226
+
227
+ await expect(result.current.triggerSchedule("sch_x")).rejects.toThrow();
228
+ expect(toast.error).toHaveBeenCalledWith(
229
+ "schedule is disabled (spec.enabled=false) — enable it before triggering",
230
+ );
231
+ });
232
+ });
233
+
234
+ describe("useSetScheduleEnabled", () => {
235
+ it("re-applies the full proto with only spec.enabled flipped", async () => {
236
+ const apply = vi.fn(async (doc: { message: unknown }) => ({
237
+ yamlKind: "Schedule",
238
+ displayName: "Schedule",
239
+ name: "daily-fee-reminders",
240
+ slug: "daily-fee-reminders",
241
+ org: "isc",
242
+ id: "sch_01example",
243
+ message: doc.message,
244
+ }));
245
+ const client = { manifest: { apply } };
246
+
247
+ const { result } = renderHook(() => useSetScheduleEnabled(), {
248
+ wrapper: wrapper(client),
249
+ });
250
+
251
+ const applied = await result.current.setEnabled(SCHEDULE, false);
252
+
253
+ // The apply went through the manifest engine with a real
254
+ // ManifestDocument (registry handler resolved from the proto type).
255
+ expect(apply).toHaveBeenCalledOnce();
256
+ const doc = apply.mock.calls[0][0] as {
257
+ handler: { yamlKind: string };
258
+ message: Schedule;
259
+ };
260
+ expect(doc.handler.yamlKind).toBe("Schedule");
261
+
262
+ // Round-trip field preservation: everything survives except the
263
+ // flipped flag. In particular metadata.tags — the field the curated
264
+ // ScheduleInput path would have silently wiped.
265
+ const expected = clone(ScheduleSchema, SCHEDULE);
266
+ expected.spec!.enabled = false;
267
+ expect(equals(ScheduleSchema, doc.message, expected)).toBe(true);
268
+ expect(doc.message.metadata?.tags).toEqual(["billing"]);
269
+
270
+ // The caller's copy is never mutated.
271
+ expect(SCHEDULE.spec?.enabled).toBe(true);
272
+
273
+ expect((applied as Schedule).spec?.enabled).toBe(false);
274
+ expect(toast.success).toHaveBeenCalledWith("Schedule disabled");
275
+ });
276
+
277
+ it("toasts 'enabled' when turning the schedule on", async () => {
278
+ const disabled = clone(ScheduleSchema, SCHEDULE);
279
+ disabled.spec!.enabled = false;
280
+ const apply = vi.fn(async (doc: { message: unknown }) => ({
281
+ yamlKind: "Schedule",
282
+ displayName: "Schedule",
283
+ name: "daily-fee-reminders",
284
+ slug: "daily-fee-reminders",
285
+ org: "isc",
286
+ id: "sch_01example",
287
+ message: doc.message,
288
+ }));
289
+ const client = { manifest: { apply } };
290
+
291
+ const { result } = renderHook(() => useSetScheduleEnabled(), {
292
+ wrapper: wrapper(client),
293
+ });
294
+
295
+ await result.current.setEnabled(disabled, true);
296
+ expect(toast.success).toHaveBeenCalledWith("Schedule enabled");
297
+ });
298
+
299
+ it("rejects a schedule without a spec", async () => {
300
+ const bare = create(ScheduleSchema, {
301
+ metadata: { name: "x", org: "isc" },
302
+ });
303
+ const client = { manifest: { apply: vi.fn() } };
304
+
305
+ const { result } = renderHook(() => useSetScheduleEnabled(), {
306
+ wrapper: wrapper(client),
307
+ });
308
+
309
+ await expect(result.current.setEnabled(bare, true)).rejects.toThrow(/no spec/);
310
+ expect(client.manifest.apply).not.toHaveBeenCalled();
311
+ });
312
+ });