talon-agent 1.0.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.
- package/README.md +137 -0
- package/bin/talon.js +5 -0
- package/package.json +86 -0
- package/prompts/base.md +13 -0
- package/prompts/custom.md.example +22 -0
- package/prompts/dream.md +41 -0
- package/prompts/identity.md +45 -0
- package/prompts/teams.md +52 -0
- package/prompts/telegram.md +89 -0
- package/prompts/terminal.md +13 -0
- package/src/__tests__/chat-id.test.ts +91 -0
- package/src/__tests__/chat-settings.test.ts +337 -0
- package/src/__tests__/config.test.ts +546 -0
- package/src/__tests__/cron-store.test.ts +440 -0
- package/src/__tests__/daily-log.test.ts +146 -0
- package/src/__tests__/dispatcher.test.ts +383 -0
- package/src/__tests__/errors.test.ts +240 -0
- package/src/__tests__/fuzz.test.ts +302 -0
- package/src/__tests__/gateway-actions.test.ts +1453 -0
- package/src/__tests__/gateway-context.test.ts +102 -0
- package/src/__tests__/gateway-http.test.ts +245 -0
- package/src/__tests__/handlers.test.ts +351 -0
- package/src/__tests__/history-persistence.test.ts +172 -0
- package/src/__tests__/history.test.ts +659 -0
- package/src/__tests__/integration.test.ts +189 -0
- package/src/__tests__/log.test.ts +110 -0
- package/src/__tests__/media-index.test.ts +277 -0
- package/src/__tests__/plugin.test.ts +317 -0
- package/src/__tests__/prompt-builder.test.ts +71 -0
- package/src/__tests__/sessions.test.ts +594 -0
- package/src/__tests__/teams-frontend.test.ts +239 -0
- package/src/__tests__/telegram.test.ts +177 -0
- package/src/__tests__/terminal-commands.test.ts +367 -0
- package/src/__tests__/terminal-frontend.test.ts +141 -0
- package/src/__tests__/terminal-renderer.test.ts +278 -0
- package/src/__tests__/watchdog.test.ts +287 -0
- package/src/__tests__/workspace.test.ts +184 -0
- package/src/backend/claude-sdk/index.ts +438 -0
- package/src/backend/claude-sdk/tools.ts +605 -0
- package/src/backend/opencode/index.ts +252 -0
- package/src/bootstrap.ts +134 -0
- package/src/cli.ts +611 -0
- package/src/core/cron.ts +148 -0
- package/src/core/dispatcher.ts +126 -0
- package/src/core/dream.ts +295 -0
- package/src/core/errors.ts +206 -0
- package/src/core/gateway-actions.ts +267 -0
- package/src/core/gateway.ts +258 -0
- package/src/core/plugin.ts +432 -0
- package/src/core/prompt-builder.ts +43 -0
- package/src/core/pulse.ts +175 -0
- package/src/core/types.ts +85 -0
- package/src/frontend/teams/actions.ts +101 -0
- package/src/frontend/teams/formatting.ts +220 -0
- package/src/frontend/teams/graph.ts +297 -0
- package/src/frontend/teams/index.ts +308 -0
- package/src/frontend/teams/proxy-fetch.ts +28 -0
- package/src/frontend/teams/tools.ts +177 -0
- package/src/frontend/telegram/actions.ts +437 -0
- package/src/frontend/telegram/admin.ts +178 -0
- package/src/frontend/telegram/callbacks.ts +251 -0
- package/src/frontend/telegram/commands.ts +543 -0
- package/src/frontend/telegram/formatting.ts +101 -0
- package/src/frontend/telegram/handlers.ts +1008 -0
- package/src/frontend/telegram/helpers.ts +105 -0
- package/src/frontend/telegram/index.ts +130 -0
- package/src/frontend/telegram/middleware.ts +177 -0
- package/src/frontend/telegram/userbot.ts +546 -0
- package/src/frontend/terminal/commands.ts +303 -0
- package/src/frontend/terminal/index.ts +282 -0
- package/src/frontend/terminal/input.ts +297 -0
- package/src/frontend/terminal/renderer.ts +248 -0
- package/src/index.ts +144 -0
- package/src/login.ts +89 -0
- package/src/storage/chat-settings.ts +218 -0
- package/src/storage/cron-store.ts +165 -0
- package/src/storage/daily-log.ts +97 -0
- package/src/storage/history.ts +278 -0
- package/src/storage/media-index.ts +116 -0
- package/src/storage/sessions.ts +328 -0
- package/src/util/chat-id.ts +21 -0
- package/src/util/config.ts +244 -0
- package/src/util/log.ts +122 -0
- package/src/util/paths.ts +80 -0
- package/src/util/time.ts +86 -0
- package/src/util/trace.ts +35 -0
- package/src/util/watchdog.ts +108 -0
- package/src/util/workspace.ts +208 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,1453 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ── Module mocks (must come before import) ──────────────────────────────────
|
|
4
|
+
|
|
5
|
+
vi.mock("../util/log.js", () => ({
|
|
6
|
+
log: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock("write-file-atomic", () => ({
|
|
10
|
+
default: { sync: vi.fn() },
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
const mockGetRecentFormatted = vi.fn(() => "history lines here");
|
|
14
|
+
const mockSearchHistory = vi.fn(() => "search results here");
|
|
15
|
+
const mockGetMessagesByUser = vi.fn(() => "user messages here");
|
|
16
|
+
const mockGetKnownUsers = vi.fn(() => "alice, bob");
|
|
17
|
+
vi.mock("../storage/history.js", () => ({
|
|
18
|
+
getRecentFormatted: mockGetRecentFormatted,
|
|
19
|
+
searchHistory: mockSearchHistory,
|
|
20
|
+
getMessagesByUser: mockGetMessagesByUser,
|
|
21
|
+
getKnownUsers: mockGetKnownUsers,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
const mockFormatMediaIndex = vi.fn(() => "media index here");
|
|
25
|
+
vi.mock("../storage/media-index.js", () => ({
|
|
26
|
+
formatMediaIndex: mockFormatMediaIndex,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
const mockAddCronJob = vi.fn();
|
|
30
|
+
const mockGetCronJob = vi.fn();
|
|
31
|
+
const mockGetCronJobsForChat = vi.fn((): any[] => []);
|
|
32
|
+
const mockUpdateCronJob = vi.fn();
|
|
33
|
+
const mockDeleteCronJob = vi.fn();
|
|
34
|
+
const mockValidateCronExpression = vi.fn((): { valid: boolean; next?: string; error?: string } => ({ valid: true, next: "2026-04-01T09:00:00.000Z" }));
|
|
35
|
+
const mockGenerateCronId = vi.fn(() => "test-id-123");
|
|
36
|
+
|
|
37
|
+
vi.mock("../storage/cron-store.js", () => ({
|
|
38
|
+
addCronJob: mockAddCronJob,
|
|
39
|
+
getCronJob: mockGetCronJob,
|
|
40
|
+
getCronJobsForChat: mockGetCronJobsForChat,
|
|
41
|
+
updateCronJob: mockUpdateCronJob,
|
|
42
|
+
deleteCronJob: mockDeleteCronJob,
|
|
43
|
+
validateCronExpression: mockValidateCronExpression,
|
|
44
|
+
generateCronId: mockGenerateCronId,
|
|
45
|
+
loadCronJobs: vi.fn(),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
// Mock node:fs for fetch_url binary download path
|
|
49
|
+
const mockExistsSync = vi.fn(() => true);
|
|
50
|
+
const mockMkdirSync = vi.fn();
|
|
51
|
+
const mockWriteFileSync = vi.fn();
|
|
52
|
+
vi.mock("node:fs", () => ({
|
|
53
|
+
existsSync: mockExistsSync,
|
|
54
|
+
mkdirSync: mockMkdirSync,
|
|
55
|
+
writeFileSync: mockWriteFileSync,
|
|
56
|
+
readFileSync: vi.fn(),
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
const { handleSharedAction } = await import("../core/gateway-actions.js");
|
|
60
|
+
|
|
61
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/** Build a minimal mock Response for `fetch`. */
|
|
64
|
+
function mockResponse(opts: {
|
|
65
|
+
ok?: boolean;
|
|
66
|
+
status?: number;
|
|
67
|
+
contentType?: string;
|
|
68
|
+
body?: string;
|
|
69
|
+
arrayBuffer?: ArrayBuffer;
|
|
70
|
+
json?: unknown;
|
|
71
|
+
}): Response {
|
|
72
|
+
const headers = new Headers();
|
|
73
|
+
if (opts.contentType) headers.set("content-type", opts.contentType);
|
|
74
|
+
return {
|
|
75
|
+
ok: opts.ok ?? true,
|
|
76
|
+
status: opts.status ?? 200,
|
|
77
|
+
headers,
|
|
78
|
+
text: async () => opts.body ?? "",
|
|
79
|
+
json: async () => opts.json ?? {},
|
|
80
|
+
arrayBuffer: async () => opts.arrayBuffer ?? new ArrayBuffer(0),
|
|
81
|
+
} as unknown as Response;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Create an ArrayBuffer with valid image magic bytes. */
|
|
85
|
+
function imageBuffer(type: "png" | "jpg" | "gif" | "webp", size = 1024): ArrayBuffer {
|
|
86
|
+
const buf = new ArrayBuffer(Math.max(size, 16));
|
|
87
|
+
const view = new Uint8Array(buf);
|
|
88
|
+
switch (type) {
|
|
89
|
+
case "png": view.set([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); break;
|
|
90
|
+
case "jpg": view.set([0xFF, 0xD8, 0xFF, 0xE0]); break;
|
|
91
|
+
case "gif": view.set([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); break;
|
|
92
|
+
case "webp": view.set([0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50]); break;
|
|
93
|
+
}
|
|
94
|
+
return buf;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
describe("gateway shared actions", () => {
|
|
100
|
+
let originalFetch: typeof globalThis.fetch;
|
|
101
|
+
let originalEnv: NodeJS.ProcessEnv;
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
originalFetch = globalThis.fetch;
|
|
105
|
+
originalEnv = { ...process.env };
|
|
106
|
+
vi.clearAllMocks();
|
|
107
|
+
// Reset env vars used by web_search
|
|
108
|
+
delete process.env.TALON_BRAVE_API_KEY;
|
|
109
|
+
delete process.env.TALON_SEARXNG_URL;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
afterEach(() => {
|
|
113
|
+
globalThis.fetch = originalFetch;
|
|
114
|
+
process.env = originalEnv;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
118
|
+
// Unknown actions
|
|
119
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
120
|
+
|
|
121
|
+
describe("unknown actions", () => {
|
|
122
|
+
it("returns null for unknown actions", async () => {
|
|
123
|
+
expect(await handleSharedAction({ action: "unknown_thing" }, 123)).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns null for empty action", async () => {
|
|
127
|
+
expect(await handleSharedAction({ action: "" }, 123)).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
132
|
+
// History actions
|
|
133
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
134
|
+
|
|
135
|
+
describe("read_history", () => {
|
|
136
|
+
it("returns formatted history with default limit", async () => {
|
|
137
|
+
const result = await handleSharedAction({ action: "read_history" }, 42);
|
|
138
|
+
expect(result).toEqual({ ok: true, text: "history lines here" });
|
|
139
|
+
expect(mockGetRecentFormatted).toHaveBeenCalledWith("42", 30);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("passes custom limit", async () => {
|
|
143
|
+
await handleSharedAction({ action: "read_history", limit: 10 }, 42);
|
|
144
|
+
expect(mockGetRecentFormatted).toHaveBeenCalledWith("42", 10);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("clamps limit to 100", async () => {
|
|
148
|
+
await handleSharedAction({ action: "read_history", limit: 500 }, 42);
|
|
149
|
+
expect(mockGetRecentFormatted).toHaveBeenCalledWith("42", 100);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("clamps limit=200 to 100", async () => {
|
|
153
|
+
await handleSharedAction({ action: "read_history", limit: 200 }, 42);
|
|
154
|
+
expect(mockGetRecentFormatted).toHaveBeenCalledWith("42", 100);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("converts chatId to string", async () => {
|
|
158
|
+
await handleSharedAction({ action: "read_history" }, 999);
|
|
159
|
+
expect(mockGetRecentFormatted).toHaveBeenCalledWith("999", 30);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("search_history", () => {
|
|
164
|
+
it("returns search results with default limit", async () => {
|
|
165
|
+
const result = await handleSharedAction({ action: "search_history", query: "hello" }, 42);
|
|
166
|
+
expect(result).toEqual({ ok: true, text: "search results here" });
|
|
167
|
+
expect(mockSearchHistory).toHaveBeenCalledWith("42", "hello", 20);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("passes custom limit", async () => {
|
|
171
|
+
await handleSharedAction({ action: "search_history", query: "test", limit: 5 }, 42);
|
|
172
|
+
expect(mockSearchHistory).toHaveBeenCalledWith("42", "test", 5);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("clamps limit to 100", async () => {
|
|
176
|
+
await handleSharedAction({ action: "search_history", query: "test", limit: 999 }, 42);
|
|
177
|
+
expect(mockSearchHistory).toHaveBeenCalledWith("42", "test", 100);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("uses empty string when query is missing", async () => {
|
|
181
|
+
await handleSharedAction({ action: "search_history" }, 42);
|
|
182
|
+
expect(mockSearchHistory).toHaveBeenCalledWith("42", "", 20);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("get_user_messages", () => {
|
|
187
|
+
it("returns user messages with default limit", async () => {
|
|
188
|
+
const result = await handleSharedAction({ action: "get_user_messages", user_name: "alice" }, 42);
|
|
189
|
+
expect(result).toEqual({ ok: true, text: "user messages here" });
|
|
190
|
+
expect(mockGetMessagesByUser).toHaveBeenCalledWith("42", "alice", 20);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("passes custom limit", async () => {
|
|
194
|
+
await handleSharedAction({ action: "get_user_messages", user_name: "bob", limit: 10 }, 42);
|
|
195
|
+
expect(mockGetMessagesByUser).toHaveBeenCalledWith("42", "bob", 10);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("clamps limit to 50", async () => {
|
|
199
|
+
await handleSharedAction({ action: "get_user_messages", user_name: "bob", limit: 200 }, 42);
|
|
200
|
+
expect(mockGetMessagesByUser).toHaveBeenCalledWith("42", "bob", 50);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("uses empty string when user_name is missing", async () => {
|
|
204
|
+
await handleSharedAction({ action: "get_user_messages" }, 42);
|
|
205
|
+
expect(mockGetMessagesByUser).toHaveBeenCalledWith("42", "", 20);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("list_known_users", () => {
|
|
210
|
+
it("returns known users", async () => {
|
|
211
|
+
const result = await handleSharedAction({ action: "list_known_users" }, 42);
|
|
212
|
+
expect(result).toEqual({ ok: true, text: "alice, bob" });
|
|
213
|
+
expect(mockGetKnownUsers).toHaveBeenCalledWith("42");
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("list_media", () => {
|
|
218
|
+
it("returns media index with default limit", async () => {
|
|
219
|
+
const result = await handleSharedAction({ action: "list_media" }, 42);
|
|
220
|
+
expect(result).toEqual({ ok: true, text: "media index here" });
|
|
221
|
+
expect(mockFormatMediaIndex).toHaveBeenCalledWith("42", 10);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("passes custom limit", async () => {
|
|
225
|
+
await handleSharedAction({ action: "list_media", limit: 5 }, 42);
|
|
226
|
+
expect(mockFormatMediaIndex).toHaveBeenCalledWith("42", 5);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("clamps limit to 20", async () => {
|
|
230
|
+
await handleSharedAction({ action: "list_media", limit: 100 }, 42);
|
|
231
|
+
expect(mockFormatMediaIndex).toHaveBeenCalledWith("42", 20);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
236
|
+
// web_search
|
|
237
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
238
|
+
|
|
239
|
+
describe("web_search", () => {
|
|
240
|
+
it("returns error for missing query", async () => {
|
|
241
|
+
const result = await handleSharedAction({ action: "web_search" }, 123);
|
|
242
|
+
expect(result).toEqual({ ok: false, error: "Missing query" });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("returns error for empty query string", async () => {
|
|
246
|
+
const result = await handleSharedAction({ action: "web_search", query: "" }, 123);
|
|
247
|
+
expect(result).toEqual({ ok: false, error: "Missing query" });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("uses Brave API when key is configured", async () => {
|
|
251
|
+
process.env.TALON_BRAVE_API_KEY = "test-brave-key";
|
|
252
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
253
|
+
ok: true,
|
|
254
|
+
contentType: "application/json",
|
|
255
|
+
json: {
|
|
256
|
+
web: {
|
|
257
|
+
results: [
|
|
258
|
+
{ title: "Result 1", url: "https://example.com/1", description: "Description 1" },
|
|
259
|
+
{ title: "Result 2", url: "https://example.com/2", description: "Description 2" },
|
|
260
|
+
],
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
}));
|
|
264
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
265
|
+
|
|
266
|
+
const result = await handleSharedAction({ action: "web_search", query: "test query" }, 123);
|
|
267
|
+
|
|
268
|
+
expect(result?.ok).toBe(true);
|
|
269
|
+
expect(result?.text).toContain("via Brave");
|
|
270
|
+
expect(result?.text).toContain("Result 1");
|
|
271
|
+
expect(result?.text).toContain("https://example.com/1");
|
|
272
|
+
expect(result?.text).toContain("Description 1");
|
|
273
|
+
expect(result?.text).toContain("Result 2");
|
|
274
|
+
|
|
275
|
+
// Verify Brave API was called correctly
|
|
276
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
277
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
278
|
+
expect(url).toContain("api.search.brave.com");
|
|
279
|
+
expect(url).toContain("q=test%20query");
|
|
280
|
+
expect(url).toContain("count=5");
|
|
281
|
+
expect(opts.headers["X-Subscription-Token"]).toBe("test-brave-key");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("respects custom limit for Brave API", async () => {
|
|
285
|
+
process.env.TALON_BRAVE_API_KEY = "test-brave-key";
|
|
286
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
287
|
+
ok: true,
|
|
288
|
+
json: { web: { results: [{ title: "R", url: "https://r.com", description: "d" }] } },
|
|
289
|
+
}));
|
|
290
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
291
|
+
|
|
292
|
+
await handleSharedAction({ action: "web_search", query: "test", limit: 8 }, 123);
|
|
293
|
+
const [url] = mockFetch.mock.calls[0];
|
|
294
|
+
expect(url).toContain("count=8");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("clamps search limit to 10", async () => {
|
|
298
|
+
process.env.TALON_BRAVE_API_KEY = "test-brave-key";
|
|
299
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
300
|
+
ok: true,
|
|
301
|
+
json: { web: { results: [{ title: "R", url: "https://r.com", description: "d" }] } },
|
|
302
|
+
}));
|
|
303
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
304
|
+
|
|
305
|
+
await handleSharedAction({ action: "web_search", query: "test", limit: 50 }, 123);
|
|
306
|
+
const [url] = mockFetch.mock.calls[0];
|
|
307
|
+
expect(url).toContain("count=10");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("falls back to SearXNG when Brave returns non-ok", async () => {
|
|
311
|
+
process.env.TALON_BRAVE_API_KEY = "test-brave-key";
|
|
312
|
+
const mockFetch = vi.fn()
|
|
313
|
+
.mockResolvedValueOnce(mockResponse({ ok: false, status: 429 })) // Brave fails
|
|
314
|
+
.mockResolvedValueOnce(mockResponse({
|
|
315
|
+
ok: true,
|
|
316
|
+
json: {
|
|
317
|
+
results: [
|
|
318
|
+
{ title: "SearX Result", url: "https://searx.example.com", content: "SearX snippet" },
|
|
319
|
+
],
|
|
320
|
+
},
|
|
321
|
+
}));
|
|
322
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
323
|
+
|
|
324
|
+
const result = await handleSharedAction({ action: "web_search", query: "fallback test" }, 123);
|
|
325
|
+
|
|
326
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
327
|
+
expect(result?.ok).toBe(true);
|
|
328
|
+
expect(result?.text).toContain("via SearXNG");
|
|
329
|
+
expect(result?.text).toContain("SearX Result");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("falls back to SearXNG when Brave throws an error", async () => {
|
|
333
|
+
process.env.TALON_BRAVE_API_KEY = "test-brave-key";
|
|
334
|
+
const mockFetch = vi.fn()
|
|
335
|
+
.mockRejectedValueOnce(new Error("network error")) // Brave throws
|
|
336
|
+
.mockResolvedValueOnce(mockResponse({
|
|
337
|
+
ok: true,
|
|
338
|
+
json: {
|
|
339
|
+
results: [
|
|
340
|
+
{ title: "Fallback", url: "https://fb.com", content: "snippet" },
|
|
341
|
+
],
|
|
342
|
+
},
|
|
343
|
+
}));
|
|
344
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
345
|
+
|
|
346
|
+
const result = await handleSharedAction({ action: "web_search", query: "test" }, 123);
|
|
347
|
+
|
|
348
|
+
expect(result?.ok).toBe(true);
|
|
349
|
+
expect(result?.text).toContain("via SearXNG");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("uses SearXNG directly when no Brave key", async () => {
|
|
353
|
+
// No TALON_BRAVE_API_KEY set
|
|
354
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
355
|
+
ok: true,
|
|
356
|
+
json: {
|
|
357
|
+
results: [
|
|
358
|
+
{ title: "Direct SearX", url: "https://searx.com/r", content: "content here" },
|
|
359
|
+
],
|
|
360
|
+
},
|
|
361
|
+
}));
|
|
362
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
363
|
+
|
|
364
|
+
const result = await handleSharedAction({ action: "web_search", query: "direct" }, 123);
|
|
365
|
+
|
|
366
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
367
|
+
const [url] = mockFetch.mock.calls[0];
|
|
368
|
+
expect(url).toContain("localhost:8080");
|
|
369
|
+
expect(url).toContain("format=json");
|
|
370
|
+
expect(result?.ok).toBe(true);
|
|
371
|
+
expect(result?.text).toContain("via SearXNG");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("uses custom SearXNG URL from env", async () => {
|
|
375
|
+
process.env.TALON_SEARXNG_URL = "http://my-searx:9090";
|
|
376
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
377
|
+
ok: true,
|
|
378
|
+
json: { results: [{ title: "T", url: "https://t.com", content: "c" }] },
|
|
379
|
+
}));
|
|
380
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
381
|
+
|
|
382
|
+
await handleSharedAction({ action: "web_search", query: "custom" }, 123);
|
|
383
|
+
|
|
384
|
+
const [url] = mockFetch.mock.calls[0];
|
|
385
|
+
expect(url).toContain("my-searx:9090");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("returns 'no results' when both providers fail", async () => {
|
|
389
|
+
process.env.TALON_BRAVE_API_KEY = "test-key";
|
|
390
|
+
const mockFetch = vi.fn()
|
|
391
|
+
.mockRejectedValueOnce(new Error("brave fail"))
|
|
392
|
+
.mockRejectedValueOnce(new Error("searx fail"));
|
|
393
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
394
|
+
|
|
395
|
+
const result = await handleSharedAction({ action: "web_search", query: "nothing" }, 123);
|
|
396
|
+
|
|
397
|
+
expect(result?.ok).toBe(true);
|
|
398
|
+
expect(result?.text).toBe('No results for "nothing".');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("returns 'no results' when both return non-ok", async () => {
|
|
402
|
+
process.env.TALON_BRAVE_API_KEY = "test-key";
|
|
403
|
+
const mockFetch = vi.fn()
|
|
404
|
+
.mockResolvedValueOnce(mockResponse({ ok: false, status: 500 }))
|
|
405
|
+
.mockResolvedValueOnce(mockResponse({ ok: false, status: 503 }));
|
|
406
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
407
|
+
|
|
408
|
+
const result = await handleSharedAction({ action: "web_search", query: "failing" }, 123);
|
|
409
|
+
|
|
410
|
+
expect(result?.ok).toBe(true);
|
|
411
|
+
expect(result?.text).toBe('No results for "failing".');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("returns 'no results' when Brave returns empty results array", async () => {
|
|
415
|
+
process.env.TALON_BRAVE_API_KEY = "test-key";
|
|
416
|
+
const mockFetch = vi.fn()
|
|
417
|
+
.mockResolvedValueOnce(mockResponse({ ok: true, json: { web: { results: [] } } }))
|
|
418
|
+
.mockResolvedValueOnce(mockResponse({ ok: true, json: { results: [] } }));
|
|
419
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
420
|
+
|
|
421
|
+
const result = await handleSharedAction({ action: "web_search", query: "empty" }, 123);
|
|
422
|
+
|
|
423
|
+
expect(result?.ok).toBe(true);
|
|
424
|
+
expect(result?.text).toBe('No results for "empty".');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("handles Brave response with missing web field", async () => {
|
|
428
|
+
process.env.TALON_BRAVE_API_KEY = "test-key";
|
|
429
|
+
const mockFetch = vi.fn()
|
|
430
|
+
.mockResolvedValueOnce(mockResponse({ ok: true, json: {} })) // no web field
|
|
431
|
+
.mockResolvedValueOnce(mockResponse({
|
|
432
|
+
ok: true,
|
|
433
|
+
json: { results: [{ title: "FallbackR", url: "https://f.com", content: "fb" }] },
|
|
434
|
+
}));
|
|
435
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
436
|
+
|
|
437
|
+
const result = await handleSharedAction({ action: "web_search", query: "test" }, 123);
|
|
438
|
+
expect(result?.text).toContain("via SearXNG");
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("truncates long snippets to 200 chars", async () => {
|
|
442
|
+
process.env.TALON_BRAVE_API_KEY = "test-key";
|
|
443
|
+
const longDesc = "A".repeat(500);
|
|
444
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
445
|
+
ok: true,
|
|
446
|
+
json: { web: { results: [{ title: "Long", url: "https://l.com", description: longDesc }] } },
|
|
447
|
+
}));
|
|
448
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
449
|
+
|
|
450
|
+
const result = await handleSharedAction({ action: "web_search", query: "long" }, 123);
|
|
451
|
+
// The snippet should be sliced to 200 chars
|
|
452
|
+
expect(result?.text).not.toContain("A".repeat(201));
|
|
453
|
+
expect(result?.text).toContain("A".repeat(200));
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("handles missing description in Brave results", async () => {
|
|
457
|
+
process.env.TALON_BRAVE_API_KEY = "test-key";
|
|
458
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
459
|
+
ok: true,
|
|
460
|
+
json: { web: { results: [{ title: "NoDesc", url: "https://nd.com" }] } },
|
|
461
|
+
}));
|
|
462
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
463
|
+
|
|
464
|
+
const result = await handleSharedAction({ action: "web_search", query: "nodesc" }, 123);
|
|
465
|
+
expect(result?.ok).toBe(true);
|
|
466
|
+
expect(result?.text).toContain("NoDesc");
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("slices SearXNG results to limit", async () => {
|
|
470
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
471
|
+
ok: true,
|
|
472
|
+
json: {
|
|
473
|
+
results: Array.from({ length: 20 }, (_, i) => ({
|
|
474
|
+
title: `R${i}`, url: `https://r${i}.com`, content: `c${i}`,
|
|
475
|
+
})),
|
|
476
|
+
},
|
|
477
|
+
}));
|
|
478
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
479
|
+
|
|
480
|
+
const result = await handleSharedAction({ action: "web_search", query: "many", limit: 3 }, 123);
|
|
481
|
+
// Should only contain 3 results (numbered 1-3)
|
|
482
|
+
expect(result?.text).toContain("1. R0");
|
|
483
|
+
expect(result?.text).toContain("3. R2");
|
|
484
|
+
expect(result?.text).not.toContain("4. R3");
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
489
|
+
// fetch_url
|
|
490
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
491
|
+
|
|
492
|
+
describe("fetch_url", () => {
|
|
493
|
+
it("rejects missing URL", async () => {
|
|
494
|
+
const result = await handleSharedAction({ action: "fetch_url" }, 123);
|
|
495
|
+
expect(result).toEqual({ ok: false, error: "Missing URL" });
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("rejects non-http protocols", async () => {
|
|
499
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "ftp://example.com" }, 123);
|
|
500
|
+
expect(result?.ok).toBe(false);
|
|
501
|
+
expect(result?.error).toContain("http or https");
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("rejects malformed URLs", async () => {
|
|
505
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "not a url at all" }, 123);
|
|
506
|
+
expect(result).toEqual({ ok: false, error: "Invalid URL" });
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("rejects javascript: protocol", async () => {
|
|
510
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "javascript:alert(1)" }, 123);
|
|
511
|
+
expect(result?.ok).toBe(false);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("rejects data: protocol", async () => {
|
|
515
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "data:text/html,<h1>hi</h1>" }, 123);
|
|
516
|
+
expect(result?.ok).toBe(false);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("fetches text page and strips HTML", async () => {
|
|
520
|
+
const htmlBody = `<html><head><title>Test</title></head><body>
|
|
521
|
+
<script>var x = 1;</script>
|
|
522
|
+
<style>.foo { color: red; }</style>
|
|
523
|
+
<h1>Hello World</h1>
|
|
524
|
+
<p>This is a & test with <tags> and entities.</p>
|
|
525
|
+
</body></html>`;
|
|
526
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
527
|
+
ok: true,
|
|
528
|
+
contentType: "text/html; charset=utf-8",
|
|
529
|
+
body: htmlBody,
|
|
530
|
+
}));
|
|
531
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
532
|
+
|
|
533
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com" }, 123);
|
|
534
|
+
|
|
535
|
+
expect(result?.ok).toBe(true);
|
|
536
|
+
// Script and style content should be stripped
|
|
537
|
+
expect(result?.text).not.toContain("var x = 1");
|
|
538
|
+
expect(result?.text).not.toContain("color: red");
|
|
539
|
+
// HTML tags stripped, entities decoded
|
|
540
|
+
expect(result?.text).toContain("Hello World");
|
|
541
|
+
expect(result?.text).toContain("This is a & test with <tags> and");
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("returns JSON content as text", async () => {
|
|
545
|
+
const jsonBody = '{"key": "value", "count": 42}';
|
|
546
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
547
|
+
ok: true,
|
|
548
|
+
contentType: "application/json",
|
|
549
|
+
body: jsonBody,
|
|
550
|
+
}));
|
|
551
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
552
|
+
|
|
553
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "https://api.example.com/data" }, 123);
|
|
554
|
+
|
|
555
|
+
expect(result?.ok).toBe(true);
|
|
556
|
+
expect(result?.text).toContain('"key": "value"');
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("truncates large text content to 8000 chars", async () => {
|
|
560
|
+
const longText = "A".repeat(10000);
|
|
561
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
562
|
+
ok: true,
|
|
563
|
+
contentType: "text/plain",
|
|
564
|
+
body: longText,
|
|
565
|
+
}));
|
|
566
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
567
|
+
|
|
568
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/big" }, 123);
|
|
569
|
+
|
|
570
|
+
expect(result?.ok).toBe(true);
|
|
571
|
+
expect(result?.text!.length).toBe(8000);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("returns message for pages with no readable content", async () => {
|
|
575
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
576
|
+
ok: true,
|
|
577
|
+
contentType: "text/html",
|
|
578
|
+
body: "<html><body> </body></html>",
|
|
579
|
+
}));
|
|
580
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
581
|
+
|
|
582
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "https://empty.com" }, 123);
|
|
583
|
+
|
|
584
|
+
expect(result?.ok).toBe(true);
|
|
585
|
+
expect(result?.text).toBe("(Page has no readable content)");
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("returns HTTP error for non-ok response", async () => {
|
|
589
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
590
|
+
ok: false,
|
|
591
|
+
status: 404,
|
|
592
|
+
}));
|
|
593
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
594
|
+
|
|
595
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/missing" }, 123);
|
|
596
|
+
|
|
597
|
+
expect(result).toEqual({ ok: false, error: "HTTP 404" });
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it("returns HTTP 500 error", async () => {
|
|
601
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
602
|
+
ok: false,
|
|
603
|
+
status: 500,
|
|
604
|
+
}));
|
|
605
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
606
|
+
|
|
607
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/error" }, 123);
|
|
608
|
+
|
|
609
|
+
expect(result).toEqual({ ok: false, error: "HTTP 500" });
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it("handles network errors", async () => {
|
|
613
|
+
const mockFetch = vi.fn().mockRejectedValueOnce(new Error("ECONNREFUSED"));
|
|
614
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
615
|
+
|
|
616
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "https://down.example.com" }, 123);
|
|
617
|
+
|
|
618
|
+
expect(result?.ok).toBe(false);
|
|
619
|
+
expect(result?.error).toContain("Fetch failed: ECONNREFUSED");
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("handles timeout errors", async () => {
|
|
623
|
+
const mockFetch = vi.fn().mockRejectedValueOnce(new Error("The operation was aborted due to timeout"));
|
|
624
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
625
|
+
|
|
626
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "https://slow.example.com" }, 123);
|
|
627
|
+
|
|
628
|
+
expect(result?.ok).toBe(false);
|
|
629
|
+
expect(result?.error).toContain("Fetch failed");
|
|
630
|
+
expect(result?.error).toContain("timeout");
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it("handles non-Error thrown values", async () => {
|
|
634
|
+
const mockFetch = vi.fn().mockRejectedValueOnce("string error");
|
|
635
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
636
|
+
|
|
637
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "https://weird.example.com" }, 123);
|
|
638
|
+
|
|
639
|
+
expect(result?.ok).toBe(false);
|
|
640
|
+
expect(result?.error).toBe("Fetch failed: string error");
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// ── Binary download tests ─────────────────────────────────────────────
|
|
644
|
+
|
|
645
|
+
it("downloads binary PNG file", async () => {
|
|
646
|
+
const buffer = imageBuffer("png");
|
|
647
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
648
|
+
ok: true,
|
|
649
|
+
contentType: "image/png",
|
|
650
|
+
arrayBuffer: buffer,
|
|
651
|
+
}));
|
|
652
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
653
|
+
mockExistsSync.mockReturnValue(true);
|
|
654
|
+
|
|
655
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/img.png" }, 123);
|
|
656
|
+
|
|
657
|
+
expect(result?.ok).toBe(true);
|
|
658
|
+
expect(result?.text).toContain("Downloaded image");
|
|
659
|
+
expect(result?.text).toContain("1KB");
|
|
660
|
+
expect(result?.text).toContain(".png");
|
|
661
|
+
expect(mockWriteFileSync).toHaveBeenCalledTimes(1);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it("downloads JPEG file", async () => {
|
|
665
|
+
const buffer = imageBuffer("jpg", 2048);
|
|
666
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
667
|
+
ok: true,
|
|
668
|
+
contentType: "image/jpeg",
|
|
669
|
+
arrayBuffer: buffer,
|
|
670
|
+
}));
|
|
671
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
672
|
+
mockExistsSync.mockReturnValue(true);
|
|
673
|
+
|
|
674
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/photo.jpg" }, 123);
|
|
675
|
+
|
|
676
|
+
expect(result?.ok).toBe(true);
|
|
677
|
+
expect(result?.text).toContain(".jpg");
|
|
678
|
+
expect(result?.text).toContain("image");
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it("downloads GIF file", async () => {
|
|
682
|
+
const buffer = imageBuffer("gif", 512);
|
|
683
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
684
|
+
ok: true,
|
|
685
|
+
contentType: "image/gif",
|
|
686
|
+
arrayBuffer: buffer,
|
|
687
|
+
}));
|
|
688
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
689
|
+
|
|
690
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/anim.gif" }, 123);
|
|
691
|
+
|
|
692
|
+
expect(result?.ok).toBe(true);
|
|
693
|
+
expect(result?.text).toContain(".gif");
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("downloads WebP file", async () => {
|
|
697
|
+
const buffer = imageBuffer("webp", 512);
|
|
698
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
699
|
+
ok: true,
|
|
700
|
+
contentType: "image/webp",
|
|
701
|
+
arrayBuffer: buffer,
|
|
702
|
+
}));
|
|
703
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
704
|
+
|
|
705
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/pic.webp" }, 123);
|
|
706
|
+
|
|
707
|
+
expect(result?.ok).toBe(true);
|
|
708
|
+
expect(result?.text).toContain(".webp");
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it("downloads PDF file", async () => {
|
|
712
|
+
const buffer = new ArrayBuffer(4096);
|
|
713
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
714
|
+
ok: true,
|
|
715
|
+
contentType: "application/pdf",
|
|
716
|
+
arrayBuffer: buffer,
|
|
717
|
+
}));
|
|
718
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
719
|
+
|
|
720
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/doc.pdf" }, 123);
|
|
721
|
+
|
|
722
|
+
expect(result?.ok).toBe(true);
|
|
723
|
+
expect(result?.text).toContain(".pdf");
|
|
724
|
+
expect(result?.text).toContain("pdf");
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
it("downloads ZIP file", async () => {
|
|
728
|
+
const buffer = new ArrayBuffer(8192);
|
|
729
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
730
|
+
ok: true,
|
|
731
|
+
contentType: "application/zip",
|
|
732
|
+
arrayBuffer: buffer,
|
|
733
|
+
}));
|
|
734
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
735
|
+
|
|
736
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/archive.zip" }, 123);
|
|
737
|
+
|
|
738
|
+
expect(result?.ok).toBe(true);
|
|
739
|
+
expect(result?.text).toContain(".zip");
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it("uses .bin extension for unknown binary types", async () => {
|
|
743
|
+
const buffer = new ArrayBuffer(256);
|
|
744
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
745
|
+
ok: true,
|
|
746
|
+
contentType: "application/octet-stream",
|
|
747
|
+
arrayBuffer: buffer,
|
|
748
|
+
}));
|
|
749
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
750
|
+
|
|
751
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/data" }, 123);
|
|
752
|
+
|
|
753
|
+
expect(result?.ok).toBe(true);
|
|
754
|
+
expect(result?.text).toContain(".bin");
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it("creates uploads directory when it does not exist", async () => {
|
|
758
|
+
const buffer = imageBuffer("png", 256);
|
|
759
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
760
|
+
ok: true,
|
|
761
|
+
contentType: "image/png",
|
|
762
|
+
arrayBuffer: buffer,
|
|
763
|
+
}));
|
|
764
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
765
|
+
mockExistsSync.mockReturnValue(false);
|
|
766
|
+
|
|
767
|
+
await handleSharedAction({ action: "fetch_url", url: "https://example.com/img.png" }, 123);
|
|
768
|
+
|
|
769
|
+
expect(mockMkdirSync).toHaveBeenCalledWith(expect.stringContaining("uploads"), { recursive: true });
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it("does not create uploads directory when it exists", async () => {
|
|
773
|
+
const buffer = new ArrayBuffer(256);
|
|
774
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
775
|
+
ok: true,
|
|
776
|
+
contentType: "image/png",
|
|
777
|
+
arrayBuffer: buffer,
|
|
778
|
+
}));
|
|
779
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
780
|
+
mockExistsSync.mockReturnValue(true);
|
|
781
|
+
|
|
782
|
+
await handleSharedAction({ action: "fetch_url", url: "https://example.com/img.png" }, 123);
|
|
783
|
+
|
|
784
|
+
expect(mockMkdirSync).not.toHaveBeenCalled();
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it("rejects files larger than 20MB", async () => {
|
|
788
|
+
const buffer = new ArrayBuffer(21 * 1024 * 1024); // 21MB
|
|
789
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
790
|
+
ok: true,
|
|
791
|
+
contentType: "image/png",
|
|
792
|
+
arrayBuffer: buffer,
|
|
793
|
+
}));
|
|
794
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
795
|
+
|
|
796
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/huge.png" }, 123);
|
|
797
|
+
|
|
798
|
+
expect(result?.ok).toBe(false);
|
|
799
|
+
expect(result?.error).toContain("File too large");
|
|
800
|
+
expect(result?.error).toContain("20MB");
|
|
801
|
+
expect(mockWriteFileSync).not.toHaveBeenCalled();
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it("includes send instructions in download response", async () => {
|
|
805
|
+
const buffer = imageBuffer("png");
|
|
806
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
807
|
+
ok: true,
|
|
808
|
+
contentType: "image/png",
|
|
809
|
+
arrayBuffer: buffer,
|
|
810
|
+
}));
|
|
811
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
812
|
+
|
|
813
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/img.png" }, 123);
|
|
814
|
+
|
|
815
|
+
expect(result?.text).toContain("Read it with the Read tool");
|
|
816
|
+
expect(result?.text).toContain('send(type="file"');
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
it("rejects HTML error page disguised as image (magic byte validation)", async () => {
|
|
820
|
+
const htmlError = new TextEncoder().encode("<!DOCTYPE html><html><body>Wikimedia Error</body></html>");
|
|
821
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
822
|
+
ok: true,
|
|
823
|
+
contentType: "image/jpeg",
|
|
824
|
+
arrayBuffer: htmlError.buffer,
|
|
825
|
+
}));
|
|
826
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
827
|
+
|
|
828
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "https://upload.wikimedia.org/img.jpg" }, 123);
|
|
829
|
+
|
|
830
|
+
expect(result?.ok).toBe(false);
|
|
831
|
+
expect(result?.error).toContain("error page instead of an image");
|
|
832
|
+
expect(mockWriteFileSync).not.toHaveBeenCalled();
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
it("rejects empty response", async () => {
|
|
836
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
837
|
+
ok: true,
|
|
838
|
+
contentType: "image/png",
|
|
839
|
+
arrayBuffer: new ArrayBuffer(0),
|
|
840
|
+
}));
|
|
841
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
842
|
+
|
|
843
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/empty.png" }, 123);
|
|
844
|
+
|
|
845
|
+
expect(result?.ok).toBe(false);
|
|
846
|
+
expect(result?.error).toContain("Empty response");
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
it("passes User-Agent header in fetch", async () => {
|
|
850
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
851
|
+
ok: true,
|
|
852
|
+
contentType: "text/html",
|
|
853
|
+
body: "<p>Content here is enough to pass the 20 char minimum check test</p>",
|
|
854
|
+
}));
|
|
855
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
856
|
+
|
|
857
|
+
await handleSharedAction({ action: "fetch_url", url: "https://example.com" }, 123);
|
|
858
|
+
|
|
859
|
+
expect(mockFetch).toHaveBeenCalledWith("https://example.com", expect.objectContaining({
|
|
860
|
+
headers: { "User-Agent": "Talon/1.0" },
|
|
861
|
+
redirect: "follow",
|
|
862
|
+
}));
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
it("labels non-image binary as content subtype", async () => {
|
|
866
|
+
const buffer = new ArrayBuffer(512);
|
|
867
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
|
|
868
|
+
ok: true,
|
|
869
|
+
contentType: "application/pdf",
|
|
870
|
+
arrayBuffer: buffer,
|
|
871
|
+
}));
|
|
872
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
873
|
+
|
|
874
|
+
const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/doc.pdf" }, 123);
|
|
875
|
+
|
|
876
|
+
// For non-image types, it uses ct.split("/")[1]?.split(";")[0] => "pdf"
|
|
877
|
+
expect(result?.text).toContain("Downloaded pdf");
|
|
878
|
+
});
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
882
|
+
// Cron CRUD
|
|
883
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
884
|
+
|
|
885
|
+
describe("cron CRUD", () => {
|
|
886
|
+
// ── create_cron_job ─────────────────────────────────────────────────
|
|
887
|
+
|
|
888
|
+
describe("create_cron_job", () => {
|
|
889
|
+
it("creates a cron job with all fields", async () => {
|
|
890
|
+
const result = await handleSharedAction({
|
|
891
|
+
action: "create_cron_job",
|
|
892
|
+
name: "Morning Greeting",
|
|
893
|
+
schedule: "0 9 * * *",
|
|
894
|
+
type: "message",
|
|
895
|
+
content: "Good morning!",
|
|
896
|
+
timezone: "America/New_York",
|
|
897
|
+
}, 42);
|
|
898
|
+
|
|
899
|
+
expect(result?.ok).toBe(true);
|
|
900
|
+
expect(result?.text).toContain('Created cron job "Morning Greeting"');
|
|
901
|
+
expect(result?.text).toContain("test-id-123");
|
|
902
|
+
expect(result?.text).toContain("0 9 * * *");
|
|
903
|
+
expect(result?.text).toContain("Type: message");
|
|
904
|
+
expect(mockAddCronJob).toHaveBeenCalledWith(expect.objectContaining({
|
|
905
|
+
id: "test-id-123",
|
|
906
|
+
chatId: "42",
|
|
907
|
+
schedule: "0 9 * * *",
|
|
908
|
+
type: "message",
|
|
909
|
+
content: "Good morning!",
|
|
910
|
+
name: "Morning Greeting",
|
|
911
|
+
enabled: true,
|
|
912
|
+
timezone: "America/New_York",
|
|
913
|
+
}));
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
it("uses default name when not provided", async () => {
|
|
917
|
+
await handleSharedAction({
|
|
918
|
+
action: "create_cron_job",
|
|
919
|
+
schedule: "0 9 * * *",
|
|
920
|
+
content: "hi",
|
|
921
|
+
}, 42);
|
|
922
|
+
|
|
923
|
+
expect(mockAddCronJob).toHaveBeenCalledWith(expect.objectContaining({
|
|
924
|
+
name: "Unnamed job",
|
|
925
|
+
}));
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
it("uses default type 'message' when not specified", async () => {
|
|
929
|
+
await handleSharedAction({
|
|
930
|
+
action: "create_cron_job",
|
|
931
|
+
schedule: "*/5 * * * *",
|
|
932
|
+
content: "ping",
|
|
933
|
+
}, 42);
|
|
934
|
+
|
|
935
|
+
expect(mockAddCronJob).toHaveBeenCalledWith(expect.objectContaining({
|
|
936
|
+
type: "message",
|
|
937
|
+
}));
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
it("rejects missing schedule", async () => {
|
|
941
|
+
const result = await handleSharedAction({
|
|
942
|
+
action: "create_cron_job",
|
|
943
|
+
name: "test",
|
|
944
|
+
content: "hi",
|
|
945
|
+
}, 123);
|
|
946
|
+
expect(result).toEqual({ ok: false, error: "Missing schedule expression" });
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
it("rejects missing content", async () => {
|
|
950
|
+
const result = await handleSharedAction({
|
|
951
|
+
action: "create_cron_job",
|
|
952
|
+
name: "test",
|
|
953
|
+
schedule: "0 9 * * *",
|
|
954
|
+
}, 123);
|
|
955
|
+
expect(result).toEqual({ ok: false, error: "Missing content" });
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
it("rejects content over 10,000 chars", async () => {
|
|
959
|
+
const result = await handleSharedAction({
|
|
960
|
+
action: "create_cron_job",
|
|
961
|
+
name: "test",
|
|
962
|
+
schedule: "0 9 * * *",
|
|
963
|
+
content: "x".repeat(10001),
|
|
964
|
+
}, 123);
|
|
965
|
+
expect(result?.ok).toBe(false);
|
|
966
|
+
expect(result?.error).toContain("too long");
|
|
967
|
+
expect(result?.error).toContain("10,000");
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
it("rejects invalid cron expression", async () => {
|
|
971
|
+
mockValidateCronExpression.mockReturnValueOnce({ valid: false, error: "bad syntax" });
|
|
972
|
+
|
|
973
|
+
const result = await handleSharedAction({
|
|
974
|
+
action: "create_cron_job",
|
|
975
|
+
name: "test",
|
|
976
|
+
schedule: "not valid",
|
|
977
|
+
content: "hi",
|
|
978
|
+
}, 123);
|
|
979
|
+
|
|
980
|
+
expect(result?.ok).toBe(false);
|
|
981
|
+
expect(result?.error).toContain("Invalid cron expression");
|
|
982
|
+
expect(result?.error).toContain("bad syntax");
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
it("includes next run time in response", async () => {
|
|
986
|
+
mockValidateCronExpression.mockReturnValueOnce({ valid: true, next: "2026-04-01T09:00:00.000Z" });
|
|
987
|
+
|
|
988
|
+
const result = await handleSharedAction({
|
|
989
|
+
action: "create_cron_job",
|
|
990
|
+
schedule: "0 9 * * *",
|
|
991
|
+
content: "morning",
|
|
992
|
+
}, 123);
|
|
993
|
+
|
|
994
|
+
expect(result?.text).toContain("2026-04-01T09:00:00.000Z");
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
it("shows 'unknown' when next run time is not available", async () => {
|
|
998
|
+
mockValidateCronExpression.mockReturnValueOnce({ valid: true });
|
|
999
|
+
|
|
1000
|
+
const result = await handleSharedAction({
|
|
1001
|
+
action: "create_cron_job",
|
|
1002
|
+
schedule: "0 9 * * *",
|
|
1003
|
+
content: "morning",
|
|
1004
|
+
}, 123);
|
|
1005
|
+
|
|
1006
|
+
expect(result?.text).toContain("Next run: unknown");
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
it("passes timezone to validation", async () => {
|
|
1010
|
+
await handleSharedAction({
|
|
1011
|
+
action: "create_cron_job",
|
|
1012
|
+
schedule: "0 9 * * *",
|
|
1013
|
+
content: "hi",
|
|
1014
|
+
timezone: "Europe/London",
|
|
1015
|
+
}, 123);
|
|
1016
|
+
|
|
1017
|
+
expect(mockValidateCronExpression).toHaveBeenCalledWith("0 9 * * *", "Europe/London");
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
it("passes undefined timezone when not specified", async () => {
|
|
1021
|
+
await handleSharedAction({
|
|
1022
|
+
action: "create_cron_job",
|
|
1023
|
+
schedule: "0 9 * * *",
|
|
1024
|
+
content: "hi",
|
|
1025
|
+
}, 123);
|
|
1026
|
+
|
|
1027
|
+
expect(mockValidateCronExpression).toHaveBeenCalledWith("0 9 * * *", undefined);
|
|
1028
|
+
});
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
// ── list_cron_jobs ──────────────────────────────────────────────────
|
|
1032
|
+
|
|
1033
|
+
describe("list_cron_jobs", () => {
|
|
1034
|
+
it("returns 'no cron jobs' when empty", async () => {
|
|
1035
|
+
mockGetCronJobsForChat.mockReturnValue([]);
|
|
1036
|
+
|
|
1037
|
+
const result = await handleSharedAction({ action: "list_cron_jobs" }, 42);
|
|
1038
|
+
|
|
1039
|
+
expect(result).toEqual({ ok: true, text: "No cron jobs in this chat." });
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
it("lists existing jobs with all details", async () => {
|
|
1043
|
+
mockGetCronJobsForChat.mockReturnValue([
|
|
1044
|
+
{
|
|
1045
|
+
id: "job-1",
|
|
1046
|
+
chatId: "42",
|
|
1047
|
+
schedule: "0 9 * * *",
|
|
1048
|
+
type: "message",
|
|
1049
|
+
content: "Good morning!",
|
|
1050
|
+
name: "Morning Greeting",
|
|
1051
|
+
enabled: true,
|
|
1052
|
+
createdAt: 1700000000000,
|
|
1053
|
+
runCount: 5,
|
|
1054
|
+
lastRunAt: 1700086400000,
|
|
1055
|
+
timezone: "America/New_York",
|
|
1056
|
+
},
|
|
1057
|
+
]);
|
|
1058
|
+
mockValidateCronExpression.mockReturnValueOnce({ valid: true, next: new Date("2026-04-02T09:00:00Z").toISOString() });
|
|
1059
|
+
|
|
1060
|
+
const result = await handleSharedAction({ action: "list_cron_jobs" }, 42);
|
|
1061
|
+
|
|
1062
|
+
expect(result?.ok).toBe(true);
|
|
1063
|
+
expect(result?.text).toContain("Cron jobs (1)");
|
|
1064
|
+
expect(result?.text).toContain("Morning Greeting (enabled)");
|
|
1065
|
+
expect(result?.text).toContain("ID: job-1");
|
|
1066
|
+
expect(result?.text).toContain("0 9 * * *");
|
|
1067
|
+
expect(result?.text).toContain("America/New_York");
|
|
1068
|
+
expect(result?.text).toContain("Type: message");
|
|
1069
|
+
expect(result?.text).toContain("Good morning!");
|
|
1070
|
+
expect(result?.text).toContain("Runs: 5");
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
it("shows 'never' for lastRun when not set", async () => {
|
|
1074
|
+
mockGetCronJobsForChat.mockReturnValue([
|
|
1075
|
+
{
|
|
1076
|
+
id: "job-2",
|
|
1077
|
+
chatId: "42",
|
|
1078
|
+
schedule: "*/5 * * * *",
|
|
1079
|
+
type: "query",
|
|
1080
|
+
content: "check status",
|
|
1081
|
+
name: "Status Check",
|
|
1082
|
+
enabled: true,
|
|
1083
|
+
createdAt: 1700000000000,
|
|
1084
|
+
runCount: 0,
|
|
1085
|
+
},
|
|
1086
|
+
]);
|
|
1087
|
+
mockValidateCronExpression.mockReturnValueOnce({ valid: true, next: new Date(Date.now() + 60000).toISOString() });
|
|
1088
|
+
|
|
1089
|
+
const result = await handleSharedAction({ action: "list_cron_jobs" }, 42);
|
|
1090
|
+
|
|
1091
|
+
expect(result?.text).toContain("Last: never");
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
it("shows disabled status", async () => {
|
|
1095
|
+
mockGetCronJobsForChat.mockReturnValue([
|
|
1096
|
+
{
|
|
1097
|
+
id: "job-3",
|
|
1098
|
+
chatId: "42",
|
|
1099
|
+
schedule: "0 12 * * *",
|
|
1100
|
+
type: "message",
|
|
1101
|
+
content: "lunch!",
|
|
1102
|
+
name: "Lunch Alert",
|
|
1103
|
+
enabled: false,
|
|
1104
|
+
createdAt: 1700000000000,
|
|
1105
|
+
runCount: 10,
|
|
1106
|
+
lastRunAt: 1700100000000,
|
|
1107
|
+
},
|
|
1108
|
+
]);
|
|
1109
|
+
mockValidateCronExpression.mockReturnValueOnce({ valid: true });
|
|
1110
|
+
|
|
1111
|
+
const result = await handleSharedAction({ action: "list_cron_jobs" }, 42);
|
|
1112
|
+
|
|
1113
|
+
expect(result?.text).toContain("Lunch Alert (disabled)");
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
it("shows 'unknown' for nextRun when validation returns no next", async () => {
|
|
1117
|
+
mockGetCronJobsForChat.mockReturnValue([
|
|
1118
|
+
{
|
|
1119
|
+
id: "job-4",
|
|
1120
|
+
chatId: "42",
|
|
1121
|
+
schedule: "bad",
|
|
1122
|
+
type: "message",
|
|
1123
|
+
content: "x",
|
|
1124
|
+
name: "Bad Job",
|
|
1125
|
+
enabled: true,
|
|
1126
|
+
createdAt: 1700000000000,
|
|
1127
|
+
runCount: 0,
|
|
1128
|
+
},
|
|
1129
|
+
]);
|
|
1130
|
+
mockValidateCronExpression.mockReturnValueOnce({ valid: false });
|
|
1131
|
+
|
|
1132
|
+
const result = await handleSharedAction({ action: "list_cron_jobs" }, 42);
|
|
1133
|
+
|
|
1134
|
+
expect(result?.text).toContain("Next: unknown");
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
it("truncates long content to 100 chars with ellipsis", async () => {
|
|
1138
|
+
const longContent = "B".repeat(200);
|
|
1139
|
+
mockGetCronJobsForChat.mockReturnValue([
|
|
1140
|
+
{
|
|
1141
|
+
id: "job-5",
|
|
1142
|
+
chatId: "42",
|
|
1143
|
+
schedule: "0 * * * *",
|
|
1144
|
+
type: "message",
|
|
1145
|
+
content: longContent,
|
|
1146
|
+
name: "Long",
|
|
1147
|
+
enabled: true,
|
|
1148
|
+
createdAt: 1700000000000,
|
|
1149
|
+
runCount: 0,
|
|
1150
|
+
},
|
|
1151
|
+
]);
|
|
1152
|
+
mockValidateCronExpression.mockReturnValueOnce({ valid: true, next: new Date().toISOString() });
|
|
1153
|
+
|
|
1154
|
+
const result = await handleSharedAction({ action: "list_cron_jobs" }, 42);
|
|
1155
|
+
|
|
1156
|
+
expect(result?.text).toContain("B".repeat(100) + "...");
|
|
1157
|
+
expect(result?.text).not.toContain("B".repeat(101));
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
it("lists multiple jobs", async () => {
|
|
1161
|
+
mockGetCronJobsForChat.mockReturnValue([
|
|
1162
|
+
{
|
|
1163
|
+
id: "j1", chatId: "42", schedule: "0 9 * * *", type: "message",
|
|
1164
|
+
content: "morning", name: "Job A", enabled: true, createdAt: 1700000000000, runCount: 1,
|
|
1165
|
+
},
|
|
1166
|
+
{
|
|
1167
|
+
id: "j2", chatId: "42", schedule: "0 17 * * *", type: "query",
|
|
1168
|
+
content: "evening", name: "Job B", enabled: false, createdAt: 1700000000000, runCount: 2,
|
|
1169
|
+
},
|
|
1170
|
+
]);
|
|
1171
|
+
mockValidateCronExpression.mockReturnValueOnce({ valid: true, next: new Date().toISOString() });
|
|
1172
|
+
mockValidateCronExpression.mockReturnValueOnce({ valid: true, next: new Date().toISOString() });
|
|
1173
|
+
|
|
1174
|
+
const result = await handleSharedAction({ action: "list_cron_jobs" }, 42);
|
|
1175
|
+
|
|
1176
|
+
expect(result?.text).toContain("Cron jobs (2)");
|
|
1177
|
+
expect(result?.text).toContain("Job A");
|
|
1178
|
+
expect(result?.text).toContain("Job B");
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
it("shows no timezone when not set", async () => {
|
|
1182
|
+
mockGetCronJobsForChat.mockReturnValue([
|
|
1183
|
+
{
|
|
1184
|
+
id: "j-tz", chatId: "42", schedule: "0 9 * * *", type: "message",
|
|
1185
|
+
content: "hi", name: "No TZ", enabled: true, createdAt: 1700000000000, runCount: 0,
|
|
1186
|
+
},
|
|
1187
|
+
]);
|
|
1188
|
+
mockValidateCronExpression.mockReturnValueOnce({ valid: true, next: new Date().toISOString() });
|
|
1189
|
+
|
|
1190
|
+
const result = await handleSharedAction({ action: "list_cron_jobs" }, 42);
|
|
1191
|
+
|
|
1192
|
+
// Should show schedule without timezone in parens
|
|
1193
|
+
expect(result?.text).toContain("Schedule: 0 9 * * *");
|
|
1194
|
+
expect(result?.text).not.toContain("Schedule: 0 9 * * * (");
|
|
1195
|
+
});
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
// ── edit_cron_job ───────────────────────────────────────────────────
|
|
1199
|
+
|
|
1200
|
+
describe("edit_cron_job", () => {
|
|
1201
|
+
it("rejects missing job_id", async () => {
|
|
1202
|
+
const result = await handleSharedAction({ action: "edit_cron_job" }, 123);
|
|
1203
|
+
expect(result).toEqual({ ok: false, error: "Missing job_id" });
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
it("rejects non-existent job", async () => {
|
|
1207
|
+
mockGetCronJob.mockReturnValue(undefined);
|
|
1208
|
+
|
|
1209
|
+
const result = await handleSharedAction({ action: "edit_cron_job", job_id: "nonexistent" }, 123);
|
|
1210
|
+
|
|
1211
|
+
expect(result?.ok).toBe(false);
|
|
1212
|
+
expect(result?.error).toContain("not found");
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
it("rejects job from different chat", async () => {
|
|
1216
|
+
mockGetCronJob.mockReturnValue({
|
|
1217
|
+
id: "job-x", chatId: "999", schedule: "0 9 * * *", type: "message",
|
|
1218
|
+
content: "hi", name: "Other Chat", enabled: true, createdAt: 1700000000000, runCount: 0,
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
const result = await handleSharedAction({ action: "edit_cron_job", job_id: "job-x" }, 123);
|
|
1222
|
+
|
|
1223
|
+
expect(result?.ok).toBe(false);
|
|
1224
|
+
expect(result?.error).toBe("Job belongs to a different chat");
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
it("updates job name", async () => {
|
|
1228
|
+
mockGetCronJob.mockReturnValue({
|
|
1229
|
+
id: "job-e1", chatId: "123", schedule: "0 9 * * *", type: "message",
|
|
1230
|
+
content: "hi", name: "Old Name", enabled: true, createdAt: 1700000000000, runCount: 0,
|
|
1231
|
+
});
|
|
1232
|
+
mockUpdateCronJob.mockReturnValue({ name: "New Name" });
|
|
1233
|
+
|
|
1234
|
+
const result = await handleSharedAction({
|
|
1235
|
+
action: "edit_cron_job", job_id: "job-e1", name: "New Name",
|
|
1236
|
+
}, 123);
|
|
1237
|
+
|
|
1238
|
+
expect(result?.ok).toBe(true);
|
|
1239
|
+
expect(result?.text).toContain("New Name");
|
|
1240
|
+
expect(result?.text).toContain("Fields changed: name");
|
|
1241
|
+
expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e1", { name: "New Name" });
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
it("updates content", async () => {
|
|
1245
|
+
mockGetCronJob.mockReturnValue({
|
|
1246
|
+
id: "job-e2", chatId: "123", schedule: "0 9 * * *", type: "message",
|
|
1247
|
+
content: "old", name: "Job", enabled: true, createdAt: 1700000000000, runCount: 0,
|
|
1248
|
+
});
|
|
1249
|
+
mockUpdateCronJob.mockReturnValue({ name: "Job" });
|
|
1250
|
+
|
|
1251
|
+
const result = await handleSharedAction({
|
|
1252
|
+
action: "edit_cron_job", job_id: "job-e2", content: "new content",
|
|
1253
|
+
}, 123);
|
|
1254
|
+
|
|
1255
|
+
expect(result?.ok).toBe(true);
|
|
1256
|
+
expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e2", { content: "new content" });
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
it("updates enabled flag", async () => {
|
|
1260
|
+
mockGetCronJob.mockReturnValue({
|
|
1261
|
+
id: "job-e3", chatId: "123", schedule: "0 9 * * *", type: "message",
|
|
1262
|
+
content: "hi", name: "Job", enabled: true, createdAt: 1700000000000, runCount: 0,
|
|
1263
|
+
});
|
|
1264
|
+
mockUpdateCronJob.mockReturnValue({ name: "Job" });
|
|
1265
|
+
|
|
1266
|
+
const result = await handleSharedAction({
|
|
1267
|
+
action: "edit_cron_job", job_id: "job-e3", enabled: false,
|
|
1268
|
+
}, 123);
|
|
1269
|
+
|
|
1270
|
+
expect(result?.ok).toBe(true);
|
|
1271
|
+
expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e3", { enabled: false });
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
it("updates schedule with validation", async () => {
|
|
1275
|
+
mockGetCronJob.mockReturnValue({
|
|
1276
|
+
id: "job-e4", chatId: "123", schedule: "0 9 * * *", type: "message",
|
|
1277
|
+
content: "hi", name: "Job", enabled: true, createdAt: 1700000000000, runCount: 0,
|
|
1278
|
+
timezone: "America/New_York",
|
|
1279
|
+
});
|
|
1280
|
+
mockValidateCronExpression.mockReturnValueOnce({ valid: true, next: "2026-05-01T12:00:00Z" });
|
|
1281
|
+
mockUpdateCronJob.mockReturnValue({ name: "Job" });
|
|
1282
|
+
|
|
1283
|
+
const result = await handleSharedAction({
|
|
1284
|
+
action: "edit_cron_job", job_id: "job-e4", schedule: "0 12 * * *",
|
|
1285
|
+
}, 123);
|
|
1286
|
+
|
|
1287
|
+
expect(result?.ok).toBe(true);
|
|
1288
|
+
expect(mockValidateCronExpression).toHaveBeenCalledWith("0 12 * * *", "America/New_York");
|
|
1289
|
+
expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e4", { schedule: "0 12 * * *" });
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
it("rejects invalid schedule update", async () => {
|
|
1293
|
+
mockGetCronJob.mockReturnValue({
|
|
1294
|
+
id: "job-e5", chatId: "123", schedule: "0 9 * * *", type: "message",
|
|
1295
|
+
content: "hi", name: "Job", enabled: true, createdAt: 1700000000000, runCount: 0,
|
|
1296
|
+
});
|
|
1297
|
+
mockValidateCronExpression.mockReturnValueOnce({ valid: false, error: "too many fields" });
|
|
1298
|
+
|
|
1299
|
+
const result = await handleSharedAction({
|
|
1300
|
+
action: "edit_cron_job", job_id: "job-e5", schedule: "bad schedule",
|
|
1301
|
+
}, 123);
|
|
1302
|
+
|
|
1303
|
+
expect(result?.ok).toBe(false);
|
|
1304
|
+
expect(result?.error).toContain("Invalid cron expression");
|
|
1305
|
+
expect(result?.error).toContain("too many fields");
|
|
1306
|
+
expect(mockUpdateCronJob).not.toHaveBeenCalled();
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
it("updates multiple fields at once", async () => {
|
|
1310
|
+
mockGetCronJob.mockReturnValue({
|
|
1311
|
+
id: "job-e6", chatId: "123", schedule: "0 9 * * *", type: "message",
|
|
1312
|
+
content: "hi", name: "Job", enabled: true, createdAt: 1700000000000, runCount: 0,
|
|
1313
|
+
});
|
|
1314
|
+
mockUpdateCronJob.mockReturnValue({ name: "Updated" });
|
|
1315
|
+
|
|
1316
|
+
const result = await handleSharedAction({
|
|
1317
|
+
action: "edit_cron_job", job_id: "job-e6",
|
|
1318
|
+
name: "Updated", content: "new", enabled: false, type: "query",
|
|
1319
|
+
}, 123);
|
|
1320
|
+
|
|
1321
|
+
expect(result?.ok).toBe(true);
|
|
1322
|
+
expect(result?.text).toContain("Fields changed: name, content, enabled, type");
|
|
1323
|
+
expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e6", {
|
|
1324
|
+
name: "Updated", content: "new", enabled: false, type: "query",
|
|
1325
|
+
});
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
it("updates timezone", async () => {
|
|
1329
|
+
mockGetCronJob.mockReturnValue({
|
|
1330
|
+
id: "job-e7", chatId: "123", schedule: "0 9 * * *", type: "message",
|
|
1331
|
+
content: "hi", name: "Job", enabled: true, createdAt: 1700000000000, runCount: 0,
|
|
1332
|
+
});
|
|
1333
|
+
mockUpdateCronJob.mockReturnValue({ name: "Job" });
|
|
1334
|
+
|
|
1335
|
+
const result = await handleSharedAction({
|
|
1336
|
+
action: "edit_cron_job", job_id: "job-e7", timezone: "Europe/Berlin",
|
|
1337
|
+
}, 123);
|
|
1338
|
+
|
|
1339
|
+
expect(result?.ok).toBe(true);
|
|
1340
|
+
expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e7", { timezone: "Europe/Berlin" });
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
it("clears timezone when set to empty", async () => {
|
|
1344
|
+
mockGetCronJob.mockReturnValue({
|
|
1345
|
+
id: "job-e8", chatId: "123", schedule: "0 9 * * *", type: "message",
|
|
1346
|
+
content: "hi", name: "Job", enabled: true, createdAt: 1700000000000, runCount: 0,
|
|
1347
|
+
timezone: "America/New_York",
|
|
1348
|
+
});
|
|
1349
|
+
mockUpdateCronJob.mockReturnValue({ name: "Job" });
|
|
1350
|
+
|
|
1351
|
+
const result = await handleSharedAction({
|
|
1352
|
+
action: "edit_cron_job", job_id: "job-e8", timezone: "",
|
|
1353
|
+
}, 123);
|
|
1354
|
+
|
|
1355
|
+
expect(result?.ok).toBe(true);
|
|
1356
|
+
expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e8", { timezone: undefined });
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
it("uses new timezone for schedule validation when both change", async () => {
|
|
1360
|
+
mockGetCronJob.mockReturnValue({
|
|
1361
|
+
id: "job-e9", chatId: "123", schedule: "0 9 * * *", type: "message",
|
|
1362
|
+
content: "hi", name: "Job", enabled: true, createdAt: 1700000000000, runCount: 0,
|
|
1363
|
+
timezone: "America/New_York",
|
|
1364
|
+
});
|
|
1365
|
+
mockValidateCronExpression.mockReturnValueOnce({ valid: true });
|
|
1366
|
+
mockUpdateCronJob.mockReturnValue({ name: "Job" });
|
|
1367
|
+
|
|
1368
|
+
await handleSharedAction({
|
|
1369
|
+
action: "edit_cron_job", job_id: "job-e9",
|
|
1370
|
+
schedule: "0 12 * * *", timezone: "Asia/Tokyo",
|
|
1371
|
+
}, 123);
|
|
1372
|
+
|
|
1373
|
+
// Should validate with the NEW timezone, not the old one
|
|
1374
|
+
expect(mockValidateCronExpression).toHaveBeenCalledWith("0 12 * * *", "Asia/Tokyo");
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
it("falls back to job timezone when schedule changes but timezone does not", async () => {
|
|
1378
|
+
mockGetCronJob.mockReturnValue({
|
|
1379
|
+
id: "job-e10", chatId: "123", schedule: "0 9 * * *", type: "message",
|
|
1380
|
+
content: "hi", name: "Job", enabled: true, createdAt: 1700000000000, runCount: 0,
|
|
1381
|
+
timezone: "US/Pacific",
|
|
1382
|
+
});
|
|
1383
|
+
mockValidateCronExpression.mockReturnValueOnce({ valid: true });
|
|
1384
|
+
mockUpdateCronJob.mockReturnValue({ name: "Job" });
|
|
1385
|
+
|
|
1386
|
+
await handleSharedAction({
|
|
1387
|
+
action: "edit_cron_job", job_id: "job-e10", schedule: "30 8 * * 1-5",
|
|
1388
|
+
}, 123);
|
|
1389
|
+
|
|
1390
|
+
expect(mockValidateCronExpression).toHaveBeenCalledWith("30 8 * * 1-5", "US/Pacific");
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
it("uses job_id as fallback name when updateCronJob returns no name", async () => {
|
|
1394
|
+
mockGetCronJob.mockReturnValue({
|
|
1395
|
+
id: "job-e11", chatId: "123", schedule: "0 9 * * *", type: "message",
|
|
1396
|
+
content: "hi", name: "Job", enabled: true, createdAt: 1700000000000, runCount: 0,
|
|
1397
|
+
});
|
|
1398
|
+
mockUpdateCronJob.mockReturnValue(undefined);
|
|
1399
|
+
|
|
1400
|
+
const result = await handleSharedAction({
|
|
1401
|
+
action: "edit_cron_job", job_id: "job-e11", content: "new",
|
|
1402
|
+
}, 123);
|
|
1403
|
+
|
|
1404
|
+
expect(result?.ok).toBe(true);
|
|
1405
|
+
expect(result?.text).toContain("job-e11");
|
|
1406
|
+
});
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
// ── delete_cron_job ─────────────────────────────────────────────────
|
|
1410
|
+
|
|
1411
|
+
describe("delete_cron_job", () => {
|
|
1412
|
+
it("rejects missing job_id", async () => {
|
|
1413
|
+
const result = await handleSharedAction({ action: "delete_cron_job" }, 123);
|
|
1414
|
+
expect(result).toEqual({ ok: false, error: "Missing job_id" });
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
it("rejects non-existent job", async () => {
|
|
1418
|
+
mockGetCronJob.mockReturnValue(undefined);
|
|
1419
|
+
|
|
1420
|
+
const result = await handleSharedAction({ action: "delete_cron_job", job_id: "ghost" }, 123);
|
|
1421
|
+
|
|
1422
|
+
expect(result?.ok).toBe(false);
|
|
1423
|
+
expect(result?.error).toContain("not found");
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
it("rejects job from different chat", async () => {
|
|
1427
|
+
mockGetCronJob.mockReturnValue({
|
|
1428
|
+
id: "job-d1", chatId: "999", schedule: "0 9 * * *", type: "message",
|
|
1429
|
+
content: "hi", name: "Other", enabled: true, createdAt: 1700000000000, runCount: 0,
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
const result = await handleSharedAction({ action: "delete_cron_job", job_id: "job-d1" }, 123);
|
|
1433
|
+
|
|
1434
|
+
expect(result?.ok).toBe(false);
|
|
1435
|
+
expect(result?.error).toBe("Job belongs to a different chat");
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
it("deletes a job successfully", async () => {
|
|
1439
|
+
mockGetCronJob.mockReturnValue({
|
|
1440
|
+
id: "job-d2", chatId: "123", schedule: "0 9 * * *", type: "message",
|
|
1441
|
+
content: "hi", name: "To Delete", enabled: true, createdAt: 1700000000000, runCount: 5,
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
const result = await handleSharedAction({ action: "delete_cron_job", job_id: "job-d2" }, 123);
|
|
1445
|
+
|
|
1446
|
+
expect(result?.ok).toBe(true);
|
|
1447
|
+
expect(result?.text).toContain('Deleted cron job "To Delete"');
|
|
1448
|
+
expect(result?.text).toContain("job-d2");
|
|
1449
|
+
expect(mockDeleteCronJob).toHaveBeenCalledWith("job-d2");
|
|
1450
|
+
});
|
|
1451
|
+
});
|
|
1452
|
+
});
|
|
1453
|
+
});
|