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.
- package/.well-known/mcp/server-card.json +2 -2
- package/.well-known/mcp/servers.json +1 -1
- package/.well-known/mcp.json +2 -2
- package/dist/cli/cli.js +2 -2
- package/dist/lib/components/index.js +2 -2
- package/dist/lib/components/shadow-claw-file-viewer/shadow-claw-file-viewer.d.ts +1 -1
- package/dist/lib/components/shadow-claw-file-viewer.js +1 -1
- package/dist/lib/components/shadow-claw.js +1 -1
- package/dist/lib/core/orchestrator/orchestrator-state.d.ts +2 -1
- package/dist/lib/core/orchestrator/orchestrator.d.ts +2 -1
- package/dist/lib/core/orchestrator/utils/operations/task.d.ts +1 -0
- package/dist/lib/index.js +2 -2
- package/dist/lib/{orchestrator-Bq9ft3uG.js → orchestrator-CMN43L0F.js} +9 -13
- package/dist/lib/orchestrator-CMN43L0F.js.map +1 -0
- package/dist/lib/orchestrator-D9f5fDCb.js.map +1 -1
- package/dist/lib/{shadow-claw-MumteAJy.js → shadow-claw-4x3LqlII.js} +2 -2
- package/dist/lib/{shadow-claw-MumteAJy.js.map → shadow-claw-4x3LqlII.js.map} +1 -1
- package/dist/lib/{shadow-claw-file-viewer-CMOnFmcP.js → shadow-claw-file-viewer-CuDhkVcL.js} +21 -21
- package/dist/lib/{shadow-claw-file-viewer-CMOnFmcP.js.map → shadow-claw-file-viewer-CuDhkVcL.js.map} +1 -1
- package/dist/lib/syncWebMcpRegistration-Bbph0XZl.js.map +1 -1
- package/dist/public/.well-known/mcp/server-card.json +2 -2
- package/dist/public/.well-known/mcp/servers.json +1 -1
- package/dist/public/.well-known/mcp.json +2 -2
- package/dist/public/docs/decisions/native-web-components-and-signals.md +1 -1
- package/dist/public/docs/example/article/index.html +2 -2
- package/dist/public/docs/publishing/index.html +2 -2
- package/dist/public/docs/skill-creator/index.html +2 -2
- package/dist/public/index.html +2 -2
- package/dist/public/index.js +1 -1
- package/dist/public/main/index.html +2 -2
- package/dist/public/main/memory/index.html +2 -2
- package/dist/public/{orchestrator-BLkS_jpm.js → orchestrator-Dc_Q73La.js} +1 -1
- package/dist/public/service-worker.js +1 -1
- package/dist/public/{shadow-claw-DmrxnX3Q.js → shadow-claw-Dabb2AEp.js} +2 -2
- package/dist/public/{shadow-claw-file-viewer-a_uRA30F.js → shadow-claw-file-viewer-CDs1QGXk.js} +1 -1
- package/dist/server.js +1 -1
- package/package.json +1 -1
- package/src/cli/cli.ts +2 -2
- package/src/components/shadow-claw-file-viewer/shadow-claw-file-viewer.ts +31 -31
- package/src/core/effect.test.ts +19 -0
- package/src/core/orchestrator/orchestrator-state.ts +2 -1
- package/src/core/orchestrator/orchestrator.test.ts +686 -5
- package/src/core/orchestrator/orchestrator.ts +3 -10
- package/src/core/orchestrator/utils/compactContext.test.ts +423 -121
- package/src/core/orchestrator/utils/configurePeerJs.test.ts +27 -0
- package/src/core/orchestrator/utils/deliverResponse.test.ts +127 -0
- package/src/core/orchestrator/utils/dispatchSubagentInvoke.test.ts +23 -0
- package/src/core/orchestrator/utils/enqueue.test.ts +520 -86
- package/src/core/orchestrator/utils/handleWorkerMessage.test.ts +664 -144
- package/src/core/orchestrator/utils/initTasks.node.test.ts +147 -0
- package/src/core/orchestrator/utils/initTasks.test.ts +926 -191
- package/src/core/orchestrator/utils/initTasks.ts +5 -4
- package/src/core/orchestrator/utils/invokeAgent.test.ts +563 -110
- package/src/core/orchestrator/utils/invokeAgent.ts +8 -10
- package/src/core/orchestrator/utils/loadChannelConfigurations.test.ts +50 -0
- package/src/core/orchestrator/utils/operations/channel.test.ts +377 -170
- package/src/core/orchestrator/utils/operations/provider.test.ts +663 -85
- package/src/core/orchestrator/utils/operations/room.test.ts +74 -11
- package/src/core/orchestrator/utils/operations/task.test.ts +452 -56
- package/src/core/orchestrator/utils/operations/task.ts +1 -1
- package/src/core/orchestrator/utils/operations/vm.test.ts +66 -7
- package/src/core/orchestrator/utils/parseDirectToolCommand.test.ts +6 -0
- package/src/core/orchestrator/utils/settings.test.ts +248 -81
- package/src/core/orchestrator/utils/setupPushTaskListener.test.ts +58 -0
- package/src/core/orchestrator/utils/syncWebMcpRegistration.test.ts +341 -65
- package/src/core/orchestrator/utils/syncWebMcpRegistration.ts +4 -2
- package/src/server/routes/mcp.ts +2 -2
- package/src/stores/file-viewer.test.ts +11 -0
- package/src/stores/theme.test.ts +41 -2
- package/src/stores/toast.test.ts +25 -0
- package/src/worker/tools/bash/native-bash-executor.test.ts +31 -1
- package/src/worker/tools/git/utils/extractConflictPaths.test.ts +24 -0
- package/src/worker/tools/git/utils/parseConflictRegions.test.ts +46 -0
- package/src/worker/tools/git/utils/resolveCorsProxy.test.ts +26 -5
- package/src/worker/tools/git/utils/truncateSnippet.test.ts +16 -0
- package/src/worker/tools/node-transformers-executor.test.ts +105 -6
- package/src/worker/tools/remote-mcp/remote-mcp.test.ts +172 -47
- package/src/worker/tools/remote-mcp/utils/formatListToolsOutput.test.ts +19 -0
- package/src/worker/tools/remote-mcp/utils/isMcpReauthError.test.ts +45 -0
- package/src/worker/tools/remote-mcp/utils/resolveMcpReauth.test.ts +48 -0
- package/src/worker/tools/rooms/list-room-members.test.ts +24 -10
- package/src/worker/tools/tasks/list-tasks.test.ts +39 -8
- package/src/worker/tools/tasks/update-task.test.ts +78 -14
- package/src/worker/tools/ui/javascript.test.ts +70 -17
- package/src/worker/tools/ui/list-tool-profiles.test.ts +31 -9
- package/dist/lib/orchestrator-Bq9ft3uG.js.map +0 -1
|
@@ -1,16 +1,37 @@
|
|
|
1
|
-
import { jest } from "@jest/globals";
|
|
2
|
-
|
|
3
|
-
import {
|
|
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: {
|
|
25
|
+
events: {
|
|
26
|
+
emit: jest.fn<(...args: unknown[]) => unknown>(),
|
|
27
|
+
} as unknown as EventBus,
|
|
8
28
|
roomManager: {
|
|
9
|
-
createRoom: jest.fn().mockReturnValue(
|
|
10
|
-
invite: jest.fn().mockReturnValue(true),
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
56
|
+
afterEach(() => {
|
|
17
57
|
Object.defineProperty(globalThis, "navigator", {
|
|
18
|
-
value:
|
|
58
|
+
value: originalNavigator,
|
|
19
59
|
configurable: true,
|
|
20
60
|
});
|
|
21
|
-
expect(await shouldStartLocalScheduler()).toBe(true);
|
|
22
61
|
});
|
|
23
62
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
schedule: "*/5 * * * *",
|
|
300
|
+
prompt: "do something",
|
|
301
|
+
schedule: "* * * * *",
|
|
32
302
|
enabled: true,
|
|
33
303
|
lastRun: null,
|
|
34
304
|
createdAt: 1,
|
|
35
|
-
} as
|
|
36
|
-
"sub-1",
|
|
37
|
-
);
|
|
305
|
+
} as Task);
|
|
38
306
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
expect.
|
|
42
|
-
);
|
|
307
|
+
expect(consoleError).toHaveBeenCalled();
|
|
308
|
+
expect(mockRunTask).not.toHaveBeenCalled();
|
|
309
|
+
expect(state.schedulerTriggeredGroups.size).toBe(0);
|
|
43
310
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
});
|
|
311
|
+
consoleError.mockRestore();
|
|
312
|
+
});
|
|
47
313
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
);
|
|
59
|
-
});
|
|
318
|
+
mockRunTask.mockImplementationOnce(async () => {
|
|
319
|
+
hadGroupDuringExecution = state.schedulerTriggeredGroups.has("br:main");
|
|
320
|
+
});
|
|
60
321
|
|
|
61
|
-
|
|
62
|
-
|
|
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: "
|
|
71
|
-
schedule: "
|
|
325
|
+
prompt: "do something",
|
|
326
|
+
schedule: "* * * * *",
|
|
72
327
|
enabled: true,
|
|
73
328
|
lastRun: null,
|
|
74
329
|
createdAt: 1,
|
|
75
|
-
}
|
|
76
|
-
);
|
|
330
|
+
};
|
|
77
331
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
expect.
|
|
81
|
-
|
|
82
|
-
|
|
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 {
|