shadow-claw 1.40.0 → 1.40.1

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 (86) hide show
  1. package/.well-known/mcp/server-card.json +2 -2
  2. package/.well-known/mcp/servers.json +1 -1
  3. package/.well-known/mcp.json +2 -2
  4. package/dist/cli/cli.js +2 -2
  5. package/dist/lib/components/index.js +2 -2
  6. package/dist/lib/components/shadow-claw-file-viewer/shadow-claw-file-viewer.d.ts +1 -1
  7. package/dist/lib/components/shadow-claw-file-viewer.js +1 -1
  8. package/dist/lib/components/shadow-claw.js +1 -1
  9. package/dist/lib/core/orchestrator/orchestrator-state.d.ts +2 -1
  10. package/dist/lib/core/orchestrator/orchestrator.d.ts +2 -1
  11. package/dist/lib/core/orchestrator/utils/operations/task.d.ts +1 -0
  12. package/dist/lib/index.js +2 -2
  13. package/dist/lib/{orchestrator-Bq9ft3uG.js → orchestrator-CMN43L0F.js} +9 -13
  14. package/dist/lib/orchestrator-CMN43L0F.js.map +1 -0
  15. package/dist/lib/orchestrator-D9f5fDCb.js.map +1 -1
  16. package/dist/lib/{shadow-claw-MumteAJy.js → shadow-claw-4x3LqlII.js} +2 -2
  17. package/dist/lib/{shadow-claw-MumteAJy.js.map → shadow-claw-4x3LqlII.js.map} +1 -1
  18. package/dist/lib/{shadow-claw-file-viewer-CMOnFmcP.js → shadow-claw-file-viewer-CuDhkVcL.js} +21 -21
  19. package/dist/lib/{shadow-claw-file-viewer-CMOnFmcP.js.map → shadow-claw-file-viewer-CuDhkVcL.js.map} +1 -1
  20. package/dist/lib/syncWebMcpRegistration-Bbph0XZl.js.map +1 -1
  21. package/dist/public/.well-known/mcp/server-card.json +2 -2
  22. package/dist/public/.well-known/mcp/servers.json +1 -1
  23. package/dist/public/.well-known/mcp.json +2 -2
  24. package/dist/public/docs/decisions/native-web-components-and-signals.md +1 -1
  25. package/dist/public/docs/example/article/index.html +2 -2
  26. package/dist/public/docs/publishing/index.html +2 -2
  27. package/dist/public/docs/skill-creator/index.html +2 -2
  28. package/dist/public/index.html +2 -2
  29. package/dist/public/index.js +1 -1
  30. package/dist/public/main/index.html +2 -2
  31. package/dist/public/main/memory/index.html +2 -2
  32. package/dist/public/{orchestrator-BLkS_jpm.js → orchestrator-Dc_Q73La.js} +1 -1
  33. package/dist/public/service-worker.js +1 -1
  34. package/dist/public/{shadow-claw-DmrxnX3Q.js → shadow-claw-Dabb2AEp.js} +2 -2
  35. package/dist/public/{shadow-claw-file-viewer-a_uRA30F.js → shadow-claw-file-viewer-CDs1QGXk.js} +1 -1
  36. package/dist/server.js +1 -1
  37. package/package.json +1 -1
  38. package/src/cli/cli.ts +2 -2
  39. package/src/components/shadow-claw-file-viewer/shadow-claw-file-viewer.ts +31 -31
  40. package/src/core/effect.test.ts +19 -0
  41. package/src/core/orchestrator/orchestrator-state.ts +2 -1
  42. package/src/core/orchestrator/orchestrator.test.ts +686 -5
  43. package/src/core/orchestrator/orchestrator.ts +3 -10
  44. package/src/core/orchestrator/utils/compactContext.test.ts +423 -121
  45. package/src/core/orchestrator/utils/configurePeerJs.test.ts +27 -0
  46. package/src/core/orchestrator/utils/deliverResponse.test.ts +127 -0
  47. package/src/core/orchestrator/utils/dispatchSubagentInvoke.test.ts +23 -0
  48. package/src/core/orchestrator/utils/enqueue.test.ts +520 -86
  49. package/src/core/orchestrator/utils/handleWorkerMessage.test.ts +664 -144
  50. package/src/core/orchestrator/utils/initTasks.node.test.ts +147 -0
  51. package/src/core/orchestrator/utils/initTasks.test.ts +926 -191
  52. package/src/core/orchestrator/utils/initTasks.ts +5 -4
  53. package/src/core/orchestrator/utils/invokeAgent.test.ts +563 -110
  54. package/src/core/orchestrator/utils/invokeAgent.ts +8 -10
  55. package/src/core/orchestrator/utils/loadChannelConfigurations.test.ts +50 -0
  56. package/src/core/orchestrator/utils/operations/channel.test.ts +377 -170
  57. package/src/core/orchestrator/utils/operations/provider.test.ts +663 -85
  58. package/src/core/orchestrator/utils/operations/room.test.ts +74 -11
  59. package/src/core/orchestrator/utils/operations/task.test.ts +452 -56
  60. package/src/core/orchestrator/utils/operations/task.ts +1 -1
  61. package/src/core/orchestrator/utils/operations/vm.test.ts +66 -7
  62. package/src/core/orchestrator/utils/parseDirectToolCommand.test.ts +6 -0
  63. package/src/core/orchestrator/utils/settings.test.ts +248 -81
  64. package/src/core/orchestrator/utils/setupPushTaskListener.test.ts +58 -0
  65. package/src/core/orchestrator/utils/syncWebMcpRegistration.test.ts +341 -65
  66. package/src/core/orchestrator/utils/syncWebMcpRegistration.ts +4 -2
  67. package/src/server/routes/mcp.ts +2 -2
  68. package/src/stores/file-viewer.test.ts +11 -0
  69. package/src/stores/theme.test.ts +41 -2
  70. package/src/stores/toast.test.ts +25 -0
  71. package/src/worker/tools/bash/native-bash-executor.test.ts +31 -1
  72. package/src/worker/tools/git/utils/extractConflictPaths.test.ts +24 -0
  73. package/src/worker/tools/git/utils/parseConflictRegions.test.ts +46 -0
  74. package/src/worker/tools/git/utils/resolveCorsProxy.test.ts +26 -5
  75. package/src/worker/tools/git/utils/truncateSnippet.test.ts +16 -0
  76. package/src/worker/tools/node-transformers-executor.test.ts +105 -6
  77. package/src/worker/tools/remote-mcp/remote-mcp.test.ts +172 -47
  78. package/src/worker/tools/remote-mcp/utils/formatListToolsOutput.test.ts +19 -0
  79. package/src/worker/tools/remote-mcp/utils/isMcpReauthError.test.ts +45 -0
  80. package/src/worker/tools/remote-mcp/utils/resolveMcpReauth.test.ts +48 -0
  81. package/src/worker/tools/rooms/list-room-members.test.ts +24 -10
  82. package/src/worker/tools/tasks/list-tasks.test.ts +39 -8
  83. package/src/worker/tools/tasks/update-task.test.ts +78 -14
  84. package/src/worker/tools/ui/javascript.test.ts +70 -17
  85. package/src/worker/tools/ui/list-tool-profiles.test.ts +31 -9
  86. package/dist/lib/orchestrator-Bq9ft3uG.js.map +0 -1
@@ -1,16 +1,37 @@
1
- import { jest } from "@jest/globals";
2
-
3
- import { createRoom, inviteToRoom, leaveRoom } from "./room.js";
1
+ import { describe, expect, it, jest } from "@jest/globals";
2
+ import type { RoomInvitePayload } from "../../../../subsystems/channels/peer-protocol.js";
3
+ import type { RoomManager } from "../../../../subsystems/channels/room-manager.js";
4
+ import type { RoomMeta } from "../../../../subsystems/channels/types.js";
5
+ import type { EventBus } from "../EventBus.js";
6
+ import {
7
+ createRoom,
8
+ handleRoomInvite,
9
+ inviteToRoom,
10
+ joinRoomViaLink,
11
+ leaveRoom,
12
+ listRooms,
13
+ } from "./room.js";
4
14
 
5
15
  function makeState() {
16
+ const mockRoom: RoomMeta = {
17
+ roomId: "room1",
18
+ name: "test-room",
19
+ hostPeerId: "peer-host",
20
+ createdAt: Date.now(),
21
+ members: [],
22
+ };
23
+
6
24
  return {
7
- events: { emit: jest.fn() } as any,
25
+ events: {
26
+ emit: jest.fn<(...args: unknown[]) => unknown>(),
27
+ } as unknown as EventBus,
8
28
  roomManager: {
9
- createRoom: jest.fn().mockReturnValue({ roomId: "room1" }),
10
- invite: jest.fn().mockReturnValue(true),
11
- leaveRoom: jest.fn(),
12
- list: jest.fn().mockReturnValue([]),
13
- } as any,
29
+ createRoom: jest.fn<() => RoomMeta>().mockReturnValue(mockRoom),
30
+ invite: jest.fn<() => boolean>().mockReturnValue(true),
31
+ joinRoom: jest.fn<() => RoomMeta>().mockReturnValue(mockRoom),
32
+ leaveRoom: jest.fn<() => void>(),
33
+ list: jest.fn<() => RoomMeta[]>().mockReturnValue([mockRoom]),
34
+ } as unknown as RoomManager,
14
35
  };
15
36
  }
16
37
 
@@ -19,7 +40,9 @@ describe("room operations", () => {
19
40
  const state = makeState();
20
41
  const room = createRoom(state, "test-room");
21
42
  expect(room.roomId).toBe("room1");
22
- expect(state.events.emit).toHaveBeenCalledWith("rooms-changed", []);
43
+ expect(state.events.emit).toHaveBeenCalledWith("rooms-changed", [
44
+ expect.objectContaining({ roomId: "room1" }),
45
+ ]);
23
46
  });
24
47
 
25
48
  it("inviteToRoom delegates", () => {
@@ -28,10 +51,50 @@ describe("room operations", () => {
28
51
  expect(state.roomManager.invite).toHaveBeenCalledWith("room1", "peer1");
29
52
  });
30
53
 
54
+ it("joinRoomViaLink delegates and emits", () => {
55
+ const state = makeState();
56
+ const room = joinRoomViaLink(state, "room1", "peer-host", "test-room");
57
+ expect(room.roomId).toBe("room1");
58
+ expect(state.roomManager.joinRoom).toHaveBeenCalledWith(
59
+ "room1",
60
+ "peer-host",
61
+ "test-room",
62
+ );
63
+ expect(state.events.emit).toHaveBeenCalledWith("rooms-changed", [
64
+ expect.objectContaining({ roomId: "room1" }),
65
+ ]);
66
+ });
67
+
31
68
  it("leaveRoom delegates and emits", () => {
32
69
  const state = makeState();
33
70
  leaveRoom(state, "room1");
34
71
  expect(state.roomManager.leaveRoom).toHaveBeenCalledWith("room1");
35
- expect(state.events.emit).toHaveBeenCalledWith("rooms-changed", []);
72
+ expect(state.events.emit).toHaveBeenCalledWith("rooms-changed", [
73
+ expect.objectContaining({ roomId: "room1" }),
74
+ ]);
75
+ });
76
+
77
+ it("listRooms returns rooms from roomManager", () => {
78
+ const state = makeState();
79
+ const rooms = listRooms(state);
80
+ expect(rooms).toHaveLength(1);
81
+ expect(rooms[0].roomId).toBe("room1");
82
+ expect(state.roomManager.list).toHaveBeenCalled();
83
+ });
84
+
85
+ it("handleRoomInvite emits room-invite event", () => {
86
+ const state = {
87
+ events: {
88
+ emit: jest.fn<(...args: unknown[]) => unknown>(),
89
+ } as unknown as EventBus,
90
+ };
91
+ const invite: RoomInvitePayload = {
92
+ roomId: "room2",
93
+ roomName: "invited-room",
94
+ hostPeerId: "peer-host-2",
95
+ fromPeerId: "peer-from-2",
96
+ };
97
+ handleRoomInvite(state, invite);
98
+ expect(state.events.emit).toHaveBeenCalledWith("room-invite", invite);
36
99
  });
37
100
  });
@@ -1,86 +1,482 @@
1
- import { beforeEach, describe, expect, it, jest } from "@jest/globals";
2
-
1
+ /** @jest-environment node */
3
2
  import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ it,
8
+ jest,
9
+ } from "@jest/globals";
10
+ import type { Task } from "../../../../db/types.js";
11
+ import type { OrchestratorState } from "../../orchestrator-state.js";
12
+
13
+ const mockRunTask = jest.fn<() => Promise<void>>();
14
+ const mockShowToast = jest.fn();
15
+
16
+ jest.unstable_mockModule("../../../../stores/orchestrator.js", () => ({
17
+ orchestratorStore: {
18
+ runTask: mockRunTask,
19
+ },
20
+ }));
21
+
22
+ jest.unstable_mockModule("../../../../ui/toast.js", () => ({
23
+ showToast: mockShowToast,
24
+ }));
25
+
26
+ const {
4
27
  deleteTaskFromServer,
28
+ getTaskFetchOptions,
29
+ runTaskAsScheduled,
5
30
  shouldStartLocalScheduler,
6
31
  syncTaskToServer,
7
- } from "./task.js";
32
+ warnIfNoPushSubscription,
33
+ } = await import("./task.js");
8
34
 
9
35
  describe("task operations", () => {
36
+ let mockFetch: jest.Mock<
37
+ (url: string, init?: RequestInit) => Promise<Response>
38
+ >;
39
+ let originalNavigator: unknown;
40
+
10
41
  beforeEach(() => {
11
- (globalThis as any).fetch = (jest.fn() as any).mockResolvedValue({
12
- ok: true,
13
- });
42
+ jest.clearAllMocks();
43
+ originalNavigator = globalThis.navigator;
44
+
45
+ mockFetch = jest.fn(
46
+ async () =>
47
+ ({
48
+ ok: true,
49
+ status: 200,
50
+ }) as Response,
51
+ );
52
+
53
+ (globalThis as Record<string, unknown>).fetch = mockFetch;
14
54
  });
15
55
 
16
- it("shouldStartLocalScheduler returns true if serviceWorker missing", async () => {
56
+ afterEach(() => {
17
57
  Object.defineProperty(globalThis, "navigator", {
18
- value: { serviceWorker: undefined },
58
+ value: originalNavigator,
19
59
  configurable: true,
20
60
  });
21
- expect(await shouldStartLocalScheduler()).toBe(true);
22
61
  });
23
62
 
24
- it("syncTaskToServer includes subscriberId in request body", async () => {
25
- await syncTaskToServer(
26
- { taskServerUrl: "/schedule", taskServerEnabled: true } as any,
27
- {
63
+ describe("getTaskFetchOptions & syncTaskToServer", () => {
64
+ it("getTaskFetchOptions defaults baseOptions to empty object", () => {
65
+ const opts = getTaskFetchOptions("http://localhost:8888");
66
+ expect(opts).toEqual(
67
+ expect.objectContaining({
68
+ targetAddressSpace: "loopback",
69
+ }),
70
+ );
71
+ });
72
+
73
+ const defaultState: Pick<
74
+ OrchestratorState,
75
+ "taskServerUrl" | "taskServerEnabled" | "pushSubscriptionWarned"
76
+ > = {
77
+ taskServerUrl: "http://localhost:8888",
78
+ taskServerEnabled: true,
79
+ pushSubscriptionWarned: true,
80
+ };
81
+
82
+ const scheduledTask: Task = {
83
+ id: "t1",
84
+ groupId: "br:main",
85
+ prompt: "hello",
86
+ schedule: "*/5 * * * *",
87
+ enabled: true,
88
+ lastRun: null,
89
+ createdAt: 1,
90
+ };
91
+
92
+ it("silently succeeds when taskServerEnabled is false", async () => {
93
+ const result = await syncTaskToServer(
94
+ { ...defaultState, taskServerEnabled: false },
95
+ scheduledTask,
96
+ );
97
+ expect(result).toBe(true);
98
+ expect(mockFetch).not.toHaveBeenCalled();
99
+ });
100
+
101
+ it("calls deleteTaskFromServer if task has no schedule", async () => {
102
+ const unscheduledTask: Task = { ...scheduledTask, schedule: "" };
103
+ const result = await syncTaskToServer(
104
+ defaultState,
105
+ unscheduledTask,
106
+ "sub-del",
107
+ );
108
+ expect(result).toBe(true);
109
+ expect(mockFetch).toHaveBeenCalledWith(
110
+ "http://localhost:8888/tasks/t1?subscriberId=sub-del",
111
+ expect.objectContaining({ method: "DELETE" }),
112
+ );
113
+ });
114
+
115
+ it("syncs task to server and sets loopback targetAddressSpace for localhost / loopback IPs", async () => {
116
+ for (const host of ["localhost", "127.0.0.1", "[::1]"]) {
117
+ mockFetch.mockClear();
118
+ await syncTaskToServer(
119
+ { ...defaultState, taskServerUrl: `http://${host}:8888/` },
120
+ scheduledTask,
121
+ );
122
+ expect(mockFetch).toHaveBeenCalledWith(
123
+ `http://${host}:8888/tasks`,
124
+ expect.objectContaining({
125
+ method: "POST",
126
+ targetAddressSpace: "loopback",
127
+ }),
128
+ );
129
+ }
130
+ });
131
+
132
+ it("sets private targetAddressSpace for private IP ranges and internal domains", async () => {
133
+ const privateHosts = [
134
+ "10.0.0.1",
135
+ "172.16.0.1",
136
+ "172.31.255.255",
137
+ "192.168.1.1",
138
+ "my-server.local",
139
+ "server.lan",
140
+ "box.home",
141
+ "cluster.internal",
142
+ "singlelabelhost",
143
+ ];
144
+
145
+ for (const host of privateHosts) {
146
+ mockFetch.mockClear();
147
+ await syncTaskToServer(
148
+ { ...defaultState, taskServerUrl: `http://${host}:9000` },
149
+ scheduledTask,
150
+ "sub-private",
151
+ );
152
+ expect(mockFetch).toHaveBeenCalledWith(
153
+ `http://${host}:9000/tasks`,
154
+ expect.objectContaining({
155
+ targetAddressSpace: "private",
156
+ }),
157
+ );
158
+ }
159
+ });
160
+
161
+ it("does not set targetAddressSpace for public hosts", async () => {
162
+ await syncTaskToServer(
163
+ { ...defaultState, taskServerUrl: "https://api.example.com" },
164
+ scheduledTask,
165
+ );
166
+ expect(mockFetch).toHaveBeenCalledWith(
167
+ "https://api.example.com/tasks",
168
+ expect.not.objectContaining({
169
+ targetAddressSpace: expect.anything(),
170
+ }),
171
+ );
172
+ });
173
+
174
+ it("handles invalid URLs in getTaskFetchOptions without throwing", async () => {
175
+ await syncTaskToServer(
176
+ { ...defaultState, taskServerUrl: "http://[invalid-ipv6" },
177
+ scheduledTask,
178
+ );
179
+ expect(mockFetch).toHaveBeenCalled();
180
+ });
181
+
182
+ it("uses fallback origin when location is undefined and uses location.origin when defined", () => {
183
+ const optsWithoutLoc = getTaskFetchOptions("/relative-path");
184
+ expect(optsWithoutLoc).toBeDefined();
185
+
186
+ Object.defineProperty(globalThis, "location", {
187
+ value: { origin: "http://example.org" },
188
+ configurable: true,
189
+ });
190
+ try {
191
+ const optsWithLoc = getTaskFetchOptions("/relative-path");
192
+ expect(optsWithLoc).toBeDefined();
193
+ } finally {
194
+ delete (globalThis as Record<string, unknown>).location;
195
+ }
196
+ });
197
+
198
+ it("returns false and logs error when server rejects sync", async () => {
199
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 500 } as Response);
200
+ const consoleError = jest
201
+ .spyOn(console, "error")
202
+ .mockImplementation(() => {});
203
+
204
+ const result = await syncTaskToServer(defaultState, scheduledTask);
205
+ expect(result).toBe(false);
206
+ expect(consoleError).toHaveBeenCalledWith(
207
+ "Server rejected task sync:",
208
+ 500,
209
+ );
210
+
211
+ consoleError.mockRestore();
212
+ });
213
+
214
+ it("returns false and logs error when fetch throws", async () => {
215
+ mockFetch.mockRejectedValueOnce(new Error("Network failed"));
216
+ const consoleError = jest
217
+ .spyOn(console, "error")
218
+ .mockImplementation(() => {});
219
+
220
+ const result = await syncTaskToServer(defaultState, scheduledTask);
221
+ expect(result).toBe(false);
222
+ expect(consoleError).toHaveBeenCalledWith(
223
+ "Failed to sync task to server:",
224
+ expect.any(Error),
225
+ );
226
+
227
+ consoleError.mockRestore();
228
+ });
229
+ });
230
+
231
+ describe("deleteTaskFromServer", () => {
232
+ const defaultState: Pick<
233
+ OrchestratorState,
234
+ "taskServerUrl" | "taskServerEnabled"
235
+ > = {
236
+ taskServerUrl: "http://localhost:8888/",
237
+ taskServerEnabled: true,
238
+ };
239
+
240
+ it("silently succeeds when taskServerEnabled is false", async () => {
241
+ const result = await deleteTaskFromServer(
242
+ { ...defaultState, taskServerEnabled: false },
243
+ "t1",
244
+ );
245
+ expect(result).toBe(true);
246
+ expect(mockFetch).not.toHaveBeenCalled();
247
+ });
248
+
249
+ it("deletes task without subscriberId suffix", async () => {
250
+ const result = await deleteTaskFromServer(defaultState, "t1");
251
+ expect(result).toBe(true);
252
+ expect(mockFetch).toHaveBeenCalledWith(
253
+ "http://localhost:8888/tasks/t1",
254
+ expect.objectContaining({ method: "DELETE" }),
255
+ );
256
+ });
257
+
258
+ it("returns false when delete request fails", async () => {
259
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 404 } as Response);
260
+ const consoleError = jest
261
+ .spyOn(console, "error")
262
+ .mockImplementation(() => {});
263
+
264
+ const result = await deleteTaskFromServer(defaultState, "t1");
265
+ expect(result).toBe(false);
266
+ expect(consoleError).toHaveBeenCalledWith(
267
+ "Server rejected task deletion:",
268
+ 404,
269
+ );
270
+
271
+ consoleError.mockRestore();
272
+ });
273
+
274
+ it("returns false when delete request throws", async () => {
275
+ mockFetch.mockRejectedValueOnce(new Error("Connection reset"));
276
+ const consoleError = jest
277
+ .spyOn(console, "error")
278
+ .mockImplementation(() => {});
279
+
280
+ const result = await deleteTaskFromServer(defaultState, "t1");
281
+ expect(result).toBe(false);
282
+ expect(consoleError).toHaveBeenCalledWith(
283
+ "Failed to delete task from server:",
284
+ expect.any(Error),
285
+ );
286
+
287
+ consoleError.mockRestore();
288
+ });
289
+ });
290
+
291
+ describe("runTaskAsScheduled", () => {
292
+ it("refuses to run if task has no groupId", async () => {
293
+ const consoleError = jest
294
+ .spyOn(console, "error")
295
+ .mockImplementation(() => {});
296
+ const state = { schedulerTriggeredGroups: new Set<string>() };
297
+
298
+ await runTaskAsScheduled(state, {
28
299
  id: "t1",
29
- groupId: "br:main",
30
- prompt: "hello",
31
- schedule: "*/5 * * * *",
300
+ prompt: "do something",
301
+ schedule: "* * * * *",
32
302
  enabled: true,
33
303
  lastRun: null,
34
304
  createdAt: 1,
35
- } as any,
36
- "sub-1",
37
- );
305
+ } as Task);
38
306
 
39
- expect((globalThis as any).fetch).toHaveBeenCalledWith(
40
- "/schedule/tasks",
41
- expect.objectContaining({ method: "POST" }),
42
- );
307
+ expect(consoleError).toHaveBeenCalled();
308
+ expect(mockRunTask).not.toHaveBeenCalled();
309
+ expect(state.schedulerTriggeredGroups.size).toBe(0);
43
310
 
44
- const body = JSON.parse((globalThis as any).fetch.mock.calls[0][1].body);
45
- expect(body.subscriberId).toBe("sub-1");
46
- });
311
+ consoleError.mockRestore();
312
+ });
47
313
 
48
- it("deleteTaskFromServer includes subscriberId query parameter", async () => {
49
- await deleteTaskFromServer(
50
- { taskServerUrl: "/schedule", taskServerEnabled: true } as any,
51
- "t1",
52
- "sub-2",
53
- );
314
+ it("adds groupId to schedulerTriggeredGroups and cleans up in finally", async () => {
315
+ const state = { schedulerTriggeredGroups: new Set<string>() };
316
+ let hadGroupDuringExecution = false;
54
317
 
55
- expect((globalThis as any).fetch).toHaveBeenCalledWith(
56
- "/schedule/tasks/t1?subscriberId=sub-2",
57
- expect.objectContaining({ method: "DELETE" }),
58
- );
59
- });
318
+ mockRunTask.mockImplementationOnce(async () => {
319
+ hadGroupDuringExecution = state.schedulerTriggeredGroups.has("br:main");
320
+ });
60
321
 
61
- it("syncTaskToServer sets targetAddressSpace private for local hostnames", async () => {
62
- await syncTaskToServer(
63
- {
64
- taskServerUrl: "https://hostname:8888/schedule",
65
- taskServerEnabled: true,
66
- } as any,
67
- {
68
- id: "t2",
322
+ const task: Task = {
323
+ id: "t1",
69
324
  groupId: "br:main",
70
- prompt: "hello",
71
- schedule: "*/5 * * * *",
325
+ prompt: "do something",
326
+ schedule: "* * * * *",
72
327
  enabled: true,
73
328
  lastRun: null,
74
329
  createdAt: 1,
75
- } as any,
76
- );
330
+ };
77
331
 
78
- expect((globalThis as any).fetch).toHaveBeenCalledWith(
79
- "https://hostname:8888/schedule/tasks",
80
- expect.objectContaining({
81
- method: "POST",
82
- targetAddressSpace: "private",
83
- }),
84
- );
332
+ await runTaskAsScheduled(state, task);
333
+
334
+ expect(hadGroupDuringExecution).toBe(true);
335
+ expect(state.schedulerTriggeredGroups.has("br:main")).toBe(false);
336
+ });
337
+ });
338
+
339
+ describe("shouldStartLocalScheduler", () => {
340
+ it("returns true if navigator is undefined", async () => {
341
+ Object.defineProperty(globalThis, "navigator", {
342
+ value: undefined,
343
+ configurable: true,
344
+ });
345
+ expect(await shouldStartLocalScheduler()).toBe(true);
346
+ });
347
+
348
+ it("returns true if navigator.serviceWorker is missing", async () => {
349
+ Object.defineProperty(globalThis, "navigator", {
350
+ value: {},
351
+ configurable: true,
352
+ });
353
+ expect(await shouldStartLocalScheduler()).toBe(true);
354
+ });
355
+
356
+ it("returns true if push subscription is missing", async () => {
357
+ Object.defineProperty(globalThis, "navigator", {
358
+ value: {
359
+ serviceWorker: {
360
+ ready: Promise.resolve({
361
+ pushManager: {
362
+ getSubscription: async () => null,
363
+ },
364
+ }),
365
+ },
366
+ },
367
+ configurable: true,
368
+ });
369
+ expect(await shouldStartLocalScheduler()).toBe(true);
370
+ });
371
+
372
+ it("returns false if push subscription is active", async () => {
373
+ Object.defineProperty(globalThis, "navigator", {
374
+ value: {
375
+ serviceWorker: {
376
+ ready: Promise.resolve({
377
+ pushManager: {
378
+ getSubscription: async () => ({
379
+ endpoint: "https://push.example.com",
380
+ }),
381
+ },
382
+ }),
383
+ },
384
+ },
385
+ configurable: true,
386
+ });
387
+ expect(await shouldStartLocalScheduler()).toBe(false);
388
+ });
389
+
390
+ it("returns true if service worker check throws", async () => {
391
+ Object.defineProperty(globalThis, "navigator", {
392
+ value: {
393
+ serviceWorker: {
394
+ ready: Promise.reject(new Error("Worker rejected")),
395
+ },
396
+ },
397
+ configurable: true,
398
+ });
399
+ expect(await shouldStartLocalScheduler()).toBe(true);
400
+ });
401
+ });
402
+
403
+ describe("warnIfNoPushSubscription", () => {
404
+ it("returns early if pushSubscriptionWarned is already true", async () => {
405
+ const state = { pushSubscriptionWarned: true };
406
+ await warnIfNoPushSubscription(state);
407
+ expect(mockShowToast).not.toHaveBeenCalled();
408
+ });
409
+
410
+ it("returns early if navigator or serviceWorker is undefined", async () => {
411
+ Object.defineProperty(globalThis, "navigator", {
412
+ value: undefined,
413
+ configurable: true,
414
+ });
415
+ const state = { pushSubscriptionWarned: false };
416
+ await warnIfNoPushSubscription(state);
417
+ expect(mockShowToast).not.toHaveBeenCalled();
418
+
419
+ Object.defineProperty(globalThis, "navigator", {
420
+ value: {},
421
+ configurable: true,
422
+ });
423
+ await warnIfNoPushSubscription(state);
424
+ expect(mockShowToast).not.toHaveBeenCalled();
425
+ });
426
+
427
+ it("shows toast warning when no push subscription exists", async () => {
428
+ Object.defineProperty(globalThis, "navigator", {
429
+ value: {
430
+ serviceWorker: {
431
+ ready: Promise.resolve({
432
+ pushManager: {
433
+ getSubscription: async () => null,
434
+ },
435
+ }),
436
+ },
437
+ },
438
+ configurable: true,
439
+ });
440
+ const state = { pushSubscriptionWarned: false };
441
+ await warnIfNoPushSubscription(state);
442
+
443
+ expect(state.pushSubscriptionWarned).toBe(true);
444
+ expect(mockShowToast).toHaveBeenCalledWith(
445
+ expect.stringContaining("Push notifications are not enabled"),
446
+ { type: "warning" },
447
+ );
448
+ });
449
+
450
+ it("does not warn when active push subscription exists", async () => {
451
+ Object.defineProperty(globalThis, "navigator", {
452
+ value: {
453
+ serviceWorker: {
454
+ ready: Promise.resolve({
455
+ pushManager: {
456
+ getSubscription: async () => ({ endpoint: "active" }),
457
+ },
458
+ }),
459
+ },
460
+ },
461
+ configurable: true,
462
+ });
463
+ const state = { pushSubscriptionWarned: false };
464
+ await warnIfNoPushSubscription(state);
465
+
466
+ expect(mockShowToast).not.toHaveBeenCalled();
467
+ });
468
+
469
+ it("ignores errors thrown during serviceWorker check", async () => {
470
+ Object.defineProperty(globalThis, "navigator", {
471
+ value: {
472
+ serviceWorker: {
473
+ ready: Promise.reject(new Error("Push API denied")),
474
+ },
475
+ },
476
+ configurable: true,
477
+ });
478
+ const state = { pushSubscriptionWarned: false };
479
+ await expect(warnIfNoPushSubscription(state)).resolves.not.toThrow();
480
+ });
85
481
  });
86
482
  });
@@ -4,7 +4,7 @@ import { showToast } from "../../../../ui/toast.js";
4
4
  import type { Task } from "../../../../db/types.js";
5
5
  import type { OrchestratorState } from "../../orchestrator-state.js";
6
6
 
7
- function getTaskFetchOptions(
7
+ export function getTaskFetchOptions(
8
8
  url: string,
9
9
  baseOptions: RequestInit = {},
10
10
  ): RequestInit {