talon-agent 1.0.0 → 1.2.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/LICENSE +21 -0
- package/README.md +1 -0
- package/package.json +15 -11
- package/prompts/dream.md +7 -3
- package/prompts/heartbeat.md +30 -0
- package/prompts/identity.md +1 -0
- package/prompts/teams.md +3 -0
- package/prompts/telegram.md +1 -0
- package/src/__tests__/chat-settings.test.ts +108 -2
- package/src/__tests__/cleanup-registry.test.ts +58 -0
- package/src/__tests__/config.test.ts +118 -52
- package/src/__tests__/cron-store-extended.test.ts +661 -0
- package/src/__tests__/cron-store.test.ts +145 -11
- package/src/__tests__/daily-log.test.ts +224 -13
- package/src/__tests__/dispatcher.test.ts +424 -23
- package/src/__tests__/dream.test.ts +1028 -0
- package/src/__tests__/errors-extended.test.ts +428 -0
- package/src/__tests__/errors.test.ts +95 -3
- package/src/__tests__/fuzz.test.ts +87 -15
- package/src/__tests__/gateway-actions.test.ts +1174 -433
- package/src/__tests__/gateway-http.test.ts +210 -19
- package/src/__tests__/gateway-retry.test.ts +359 -0
- package/src/__tests__/gateway-withRetry-extended.test.ts +343 -0
- package/src/__tests__/graph.test.ts +830 -0
- package/src/__tests__/handlers-stream.test.ts +208 -0
- package/src/__tests__/handlers.test.ts +2539 -70
- package/src/__tests__/heartbeat.test.ts +364 -0
- package/src/__tests__/history-extended.test.ts +775 -0
- package/src/__tests__/history-persistence.test.ts +74 -19
- package/src/__tests__/history.test.ts +113 -79
- package/src/__tests__/integration.test.ts +43 -8
- package/src/__tests__/log-init.test.ts +129 -0
- package/src/__tests__/log.test.ts +23 -5
- package/src/__tests__/media-index.test.ts +317 -35
- package/src/__tests__/plugin.test.ts +314 -0
- package/src/__tests__/prompt-builder-extended.test.ts +296 -0
- package/src/__tests__/prompt-builder.test.ts +44 -9
- package/src/__tests__/sessions.test.ts +258 -4
- package/src/__tests__/storage-save-errors.test.ts +342 -0
- package/src/__tests__/teams-frontend.test.ts +526 -31
- package/src/__tests__/telegram-formatting.test.ts +82 -0
- package/src/__tests__/terminal-commands.test.ts +208 -1
- package/src/__tests__/terminal-renderer.test.ts +223 -0
- package/src/__tests__/time.test.ts +107 -0
- package/src/__tests__/workspace-migrate.test.ts +256 -0
- package/src/__tests__/workspace.test.ts +63 -1
- package/src/backend/claude-sdk/tools.ts +64 -18
- package/src/bootstrap.ts +14 -14
- package/src/cli.ts +440 -125
- package/src/core/cron.ts +20 -5
- package/src/core/dispatcher.ts +27 -9
- package/src/core/dream.ts +79 -24
- package/src/core/errors.ts +12 -2
- package/src/core/gateway-actions.ts +182 -46
- package/src/core/gateway.ts +93 -41
- package/src/core/heartbeat.ts +515 -0
- package/src/core/plugin.ts +1 -1
- package/src/core/prompt-builder.ts +1 -4
- package/src/core/pulse.ts +4 -3
- package/src/frontend/teams/actions.ts +3 -1
- package/src/frontend/teams/formatting.ts +47 -8
- package/src/frontend/teams/graph.ts +35 -11
- package/src/frontend/teams/index.ts +155 -57
- package/src/frontend/teams/tools.ts +4 -6
- package/src/frontend/telegram/actions.ts +358 -82
- package/src/frontend/telegram/admin.ts +162 -72
- package/src/frontend/telegram/callbacks.ts +16 -10
- package/src/frontend/telegram/commands.ts +37 -21
- package/src/frontend/telegram/formatting.ts +2 -4
- package/src/frontend/telegram/handlers.ts +262 -66
- package/src/frontend/telegram/index.ts +39 -14
- package/src/frontend/telegram/middleware.ts +14 -4
- package/src/frontend/telegram/userbot.ts +16 -4
- package/src/frontend/terminal/renderer.ts +1 -4
- package/src/index.ts +28 -4
- package/src/storage/chat-settings.ts +32 -9
- package/src/storage/cron-store.ts +53 -11
- package/src/storage/daily-log.ts +72 -19
- package/src/storage/history.ts +39 -21
- package/src/storage/media-index.ts +37 -12
- package/src/storage/sessions.ts +3 -2
- package/src/util/cleanup-registry.ts +34 -0
- package/src/util/config.ts +85 -23
- package/src/util/log.ts +47 -17
- package/src/util/paths.ts +10 -0
- package/src/util/time.ts +29 -6
- package/src/util/watchdog.ts +5 -1
- package/src/util/workspace.ts +51 -10
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
2
|
|
|
3
3
|
vi.mock("../util/log.js", () => ({
|
|
4
|
-
log: vi.fn(),
|
|
4
|
+
log: vi.fn(),
|
|
5
|
+
logError: vi.fn(),
|
|
6
|
+
logWarn: vi.fn(),
|
|
7
|
+
logDebug: vi.fn(),
|
|
5
8
|
}));
|
|
6
9
|
|
|
7
10
|
const existsSyncMock = vi.fn(() => false);
|
|
@@ -22,7 +25,14 @@ vi.mock("write-file-atomic", () => ({
|
|
|
22
25
|
default: { sync: (...args: unknown[]) => writeFileSyncMock(...args) },
|
|
23
26
|
}));
|
|
24
27
|
|
|
25
|
-
const {
|
|
28
|
+
const {
|
|
29
|
+
addMedia,
|
|
30
|
+
getRecentMedia,
|
|
31
|
+
getMediaByType,
|
|
32
|
+
formatMediaIndex,
|
|
33
|
+
loadMediaIndex,
|
|
34
|
+
flushMediaIndex,
|
|
35
|
+
} = await import("../storage/media-index.js");
|
|
26
36
|
|
|
27
37
|
describe("media-index", () => {
|
|
28
38
|
beforeEach(() => {
|
|
@@ -35,8 +45,12 @@ describe("media-index", () => {
|
|
|
35
45
|
it("adds and retrieves media", () => {
|
|
36
46
|
const cid = `add-${Date.now()}`;
|
|
37
47
|
addMedia({
|
|
38
|
-
chatId: cid,
|
|
39
|
-
|
|
48
|
+
chatId: cid,
|
|
49
|
+
msgId: 1,
|
|
50
|
+
senderName: "Alice",
|
|
51
|
+
type: "photo",
|
|
52
|
+
filePath: "/tmp/photo.jpg",
|
|
53
|
+
timestamp: Date.now(),
|
|
40
54
|
});
|
|
41
55
|
const media = getRecentMedia(cid);
|
|
42
56
|
expect(media).toHaveLength(1);
|
|
@@ -50,9 +64,30 @@ describe("media-index", () => {
|
|
|
50
64
|
|
|
51
65
|
it("filters by type", () => {
|
|
52
66
|
const cid = `type-${Date.now()}`;
|
|
53
|
-
addMedia({
|
|
54
|
-
|
|
55
|
-
|
|
67
|
+
addMedia({
|
|
68
|
+
chatId: cid,
|
|
69
|
+
msgId: 1,
|
|
70
|
+
senderName: "A",
|
|
71
|
+
type: "photo",
|
|
72
|
+
filePath: "/a.jpg",
|
|
73
|
+
timestamp: Date.now(),
|
|
74
|
+
});
|
|
75
|
+
addMedia({
|
|
76
|
+
chatId: cid,
|
|
77
|
+
msgId: 2,
|
|
78
|
+
senderName: "A",
|
|
79
|
+
type: "document",
|
|
80
|
+
filePath: "/b.pdf",
|
|
81
|
+
timestamp: Date.now(),
|
|
82
|
+
});
|
|
83
|
+
addMedia({
|
|
84
|
+
chatId: cid,
|
|
85
|
+
msgId: 3,
|
|
86
|
+
senderName: "A",
|
|
87
|
+
type: "photo",
|
|
88
|
+
filePath: "/c.jpg",
|
|
89
|
+
timestamp: Date.now(),
|
|
90
|
+
});
|
|
56
91
|
|
|
57
92
|
expect(getMediaByType(cid, "photo")).toHaveLength(2);
|
|
58
93
|
expect(getMediaByType(cid, "document")).toHaveLength(1);
|
|
@@ -60,8 +95,22 @@ describe("media-index", () => {
|
|
|
60
95
|
|
|
61
96
|
it("deduplicates by chatId:msgId", () => {
|
|
62
97
|
const chatId = `dedup-${Date.now()}`;
|
|
63
|
-
addMedia({
|
|
64
|
-
|
|
98
|
+
addMedia({
|
|
99
|
+
chatId,
|
|
100
|
+
msgId: 1,
|
|
101
|
+
senderName: "A",
|
|
102
|
+
type: "photo",
|
|
103
|
+
filePath: "/a.jpg",
|
|
104
|
+
timestamp: 1000,
|
|
105
|
+
});
|
|
106
|
+
addMedia({
|
|
107
|
+
chatId,
|
|
108
|
+
msgId: 1,
|
|
109
|
+
senderName: "A",
|
|
110
|
+
type: "photo",
|
|
111
|
+
filePath: "/b.jpg",
|
|
112
|
+
timestamp: 2000,
|
|
113
|
+
});
|
|
65
114
|
|
|
66
115
|
const media = getRecentMedia(chatId);
|
|
67
116
|
expect(media).toHaveLength(1);
|
|
@@ -69,7 +118,15 @@ describe("media-index", () => {
|
|
|
69
118
|
});
|
|
70
119
|
|
|
71
120
|
it("formats index as text", () => {
|
|
72
|
-
addMedia({
|
|
121
|
+
addMedia({
|
|
122
|
+
chatId: "456",
|
|
123
|
+
msgId: 10,
|
|
124
|
+
senderName: "Bob",
|
|
125
|
+
type: "photo",
|
|
126
|
+
filePath: "/photo.jpg",
|
|
127
|
+
caption: "sunset",
|
|
128
|
+
timestamp: Date.now(),
|
|
129
|
+
});
|
|
73
130
|
const text = formatMediaIndex("456");
|
|
74
131
|
expect(text).toContain("photo");
|
|
75
132
|
expect(text).toContain("Bob");
|
|
@@ -83,14 +140,35 @@ describe("media-index", () => {
|
|
|
83
140
|
|
|
84
141
|
it("limits results", () => {
|
|
85
142
|
for (let i = 0; i < 15; i++) {
|
|
86
|
-
addMedia({
|
|
143
|
+
addMedia({
|
|
144
|
+
chatId: "789",
|
|
145
|
+
msgId: i,
|
|
146
|
+
senderName: "C",
|
|
147
|
+
type: "photo",
|
|
148
|
+
filePath: `/p${i}.jpg`,
|
|
149
|
+
timestamp: Date.now() + i,
|
|
150
|
+
});
|
|
87
151
|
}
|
|
88
152
|
expect(getRecentMedia("789", 5)).toHaveLength(5);
|
|
89
153
|
});
|
|
90
154
|
|
|
91
155
|
it("returns newest first", () => {
|
|
92
|
-
addMedia({
|
|
93
|
-
|
|
156
|
+
addMedia({
|
|
157
|
+
chatId: "100",
|
|
158
|
+
msgId: 1,
|
|
159
|
+
senderName: "A",
|
|
160
|
+
type: "photo",
|
|
161
|
+
filePath: "/old.jpg",
|
|
162
|
+
timestamp: 1000,
|
|
163
|
+
});
|
|
164
|
+
addMedia({
|
|
165
|
+
chatId: "100",
|
|
166
|
+
msgId: 2,
|
|
167
|
+
senderName: "A",
|
|
168
|
+
type: "photo",
|
|
169
|
+
filePath: "/new.jpg",
|
|
170
|
+
timestamp: 2000,
|
|
171
|
+
});
|
|
94
172
|
|
|
95
173
|
const media = getRecentMedia("100");
|
|
96
174
|
expect(media[0].filePath).toBe("/new.jpg");
|
|
@@ -99,9 +177,24 @@ describe("media-index", () => {
|
|
|
99
177
|
describe("addMedia with all media types", () => {
|
|
100
178
|
it("supports all media type variants", () => {
|
|
101
179
|
const cid = `types-${Date.now()}`;
|
|
102
|
-
const types = [
|
|
180
|
+
const types = [
|
|
181
|
+
"photo",
|
|
182
|
+
"document",
|
|
183
|
+
"voice",
|
|
184
|
+
"video",
|
|
185
|
+
"animation",
|
|
186
|
+
"audio",
|
|
187
|
+
"sticker",
|
|
188
|
+
] as const;
|
|
103
189
|
types.forEach((type, i) => {
|
|
104
|
-
addMedia({
|
|
190
|
+
addMedia({
|
|
191
|
+
chatId: cid,
|
|
192
|
+
msgId: i + 1,
|
|
193
|
+
senderName: "User",
|
|
194
|
+
type,
|
|
195
|
+
filePath: `/tmp/${type}.bin`,
|
|
196
|
+
timestamp: Date.now() + i,
|
|
197
|
+
});
|
|
105
198
|
});
|
|
106
199
|
const media = getRecentMedia(cid, 20);
|
|
107
200
|
expect(media).toHaveLength(7);
|
|
@@ -111,14 +204,29 @@ describe("media-index", () => {
|
|
|
111
204
|
|
|
112
205
|
it("supports caption field", () => {
|
|
113
206
|
const cid = `cap-${Date.now()}`;
|
|
114
|
-
addMedia({
|
|
207
|
+
addMedia({
|
|
208
|
+
chatId: cid,
|
|
209
|
+
msgId: 1,
|
|
210
|
+
senderName: "User",
|
|
211
|
+
type: "photo",
|
|
212
|
+
filePath: "/a.jpg",
|
|
213
|
+
caption: "My caption",
|
|
214
|
+
timestamp: Date.now(),
|
|
215
|
+
});
|
|
115
216
|
const media = getRecentMedia(cid);
|
|
116
217
|
expect(media[0].caption).toBe("My caption");
|
|
117
218
|
});
|
|
118
219
|
|
|
119
220
|
it("generates correct id from chatId:msgId", () => {
|
|
120
221
|
const cid = `id-${Date.now()}`;
|
|
121
|
-
addMedia({
|
|
222
|
+
addMedia({
|
|
223
|
+
chatId: cid,
|
|
224
|
+
msgId: 42,
|
|
225
|
+
senderName: "User",
|
|
226
|
+
type: "photo",
|
|
227
|
+
filePath: "/a.jpg",
|
|
228
|
+
timestamp: Date.now(),
|
|
229
|
+
});
|
|
122
230
|
const media = getRecentMedia(cid);
|
|
123
231
|
expect(media[0].id).toBe(`${cid}:42`);
|
|
124
232
|
});
|
|
@@ -127,7 +235,14 @@ describe("media-index", () => {
|
|
|
127
235
|
describe("formatMediaIndex output format", () => {
|
|
128
236
|
it("includes timestamp in readable format", () => {
|
|
129
237
|
const ts = new Date("2025-03-15T14:30:00Z").getTime();
|
|
130
|
-
addMedia({
|
|
238
|
+
addMedia({
|
|
239
|
+
chatId: "fmt-1",
|
|
240
|
+
msgId: 1,
|
|
241
|
+
senderName: "Alice",
|
|
242
|
+
type: "document",
|
|
243
|
+
filePath: "/doc.pdf",
|
|
244
|
+
timestamp: ts,
|
|
245
|
+
});
|
|
131
246
|
const text = formatMediaIndex("fmt-1");
|
|
132
247
|
expect(text).toContain("2025-03-15 14:30");
|
|
133
248
|
expect(text).toContain("[document]");
|
|
@@ -138,7 +253,15 @@ describe("media-index", () => {
|
|
|
138
253
|
|
|
139
254
|
it("truncates long captions at 50 characters", () => {
|
|
140
255
|
const longCaption = "A".repeat(100);
|
|
141
|
-
addMedia({
|
|
256
|
+
addMedia({
|
|
257
|
+
chatId: "fmt-2",
|
|
258
|
+
msgId: 1,
|
|
259
|
+
senderName: "Bob",
|
|
260
|
+
type: "photo",
|
|
261
|
+
filePath: "/p.jpg",
|
|
262
|
+
caption: longCaption,
|
|
263
|
+
timestamp: Date.now(),
|
|
264
|
+
});
|
|
142
265
|
const text = formatMediaIndex("fmt-2");
|
|
143
266
|
// Caption should be truncated to 50 chars
|
|
144
267
|
expect(text).toContain(`"${"A".repeat(50)}"`);
|
|
@@ -146,7 +269,14 @@ describe("media-index", () => {
|
|
|
146
269
|
});
|
|
147
270
|
|
|
148
271
|
it("omits caption when not provided", () => {
|
|
149
|
-
addMedia({
|
|
272
|
+
addMedia({
|
|
273
|
+
chatId: "fmt-3",
|
|
274
|
+
msgId: 1,
|
|
275
|
+
senderName: "Bob",
|
|
276
|
+
type: "photo",
|
|
277
|
+
filePath: "/p.jpg",
|
|
278
|
+
timestamp: Date.now(),
|
|
279
|
+
});
|
|
150
280
|
const text = formatMediaIndex("fmt-3");
|
|
151
281
|
// Should not contain empty quotes
|
|
152
282
|
expect(text).not.toContain('""');
|
|
@@ -154,7 +284,14 @@ describe("media-index", () => {
|
|
|
154
284
|
|
|
155
285
|
it("respects limit parameter", () => {
|
|
156
286
|
for (let i = 0; i < 20; i++) {
|
|
157
|
-
addMedia({
|
|
287
|
+
addMedia({
|
|
288
|
+
chatId: "fmt-4",
|
|
289
|
+
msgId: i,
|
|
290
|
+
senderName: "C",
|
|
291
|
+
type: "photo",
|
|
292
|
+
filePath: `/p${i}.jpg`,
|
|
293
|
+
timestamp: Date.now() + i,
|
|
294
|
+
});
|
|
158
295
|
}
|
|
159
296
|
const text = formatMediaIndex("fmt-4", 3);
|
|
160
297
|
// Each entry has 2 lines (info + file path), so 3 entries
|
|
@@ -166,22 +303,50 @@ describe("media-index", () => {
|
|
|
166
303
|
describe("getMediaByType", () => {
|
|
167
304
|
it("returns empty array when no entries match type", () => {
|
|
168
305
|
const cid = `type-none-${Date.now()}`;
|
|
169
|
-
addMedia({
|
|
306
|
+
addMedia({
|
|
307
|
+
chatId: cid,
|
|
308
|
+
msgId: 1,
|
|
309
|
+
senderName: "A",
|
|
310
|
+
type: "photo",
|
|
311
|
+
filePath: "/a.jpg",
|
|
312
|
+
timestamp: Date.now(),
|
|
313
|
+
});
|
|
170
314
|
expect(getMediaByType(cid, "voice")).toHaveLength(0);
|
|
171
315
|
});
|
|
172
316
|
|
|
173
317
|
it("respects limit parameter", () => {
|
|
174
318
|
const cid = `type-limit-${Date.now()}`;
|
|
175
319
|
for (let i = 0; i < 15; i++) {
|
|
176
|
-
addMedia({
|
|
320
|
+
addMedia({
|
|
321
|
+
chatId: cid,
|
|
322
|
+
msgId: i,
|
|
323
|
+
senderName: "A",
|
|
324
|
+
type: "photo",
|
|
325
|
+
filePath: `/p${i}.jpg`,
|
|
326
|
+
timestamp: Date.now() + i,
|
|
327
|
+
});
|
|
177
328
|
}
|
|
178
329
|
expect(getMediaByType(cid, "photo", 5)).toHaveLength(5);
|
|
179
330
|
});
|
|
180
331
|
|
|
181
332
|
it("returns newest first", () => {
|
|
182
333
|
const cid = `type-order-${Date.now()}`;
|
|
183
|
-
addMedia({
|
|
184
|
-
|
|
334
|
+
addMedia({
|
|
335
|
+
chatId: cid,
|
|
336
|
+
msgId: 1,
|
|
337
|
+
senderName: "A",
|
|
338
|
+
type: "voice",
|
|
339
|
+
filePath: "/old.ogg",
|
|
340
|
+
timestamp: 1000,
|
|
341
|
+
});
|
|
342
|
+
addMedia({
|
|
343
|
+
chatId: cid,
|
|
344
|
+
msgId: 2,
|
|
345
|
+
senderName: "A",
|
|
346
|
+
type: "voice",
|
|
347
|
+
filePath: "/new.ogg",
|
|
348
|
+
timestamp: 2000,
|
|
349
|
+
});
|
|
185
350
|
const result = getMediaByType(cid, "voice");
|
|
186
351
|
expect(result[0].filePath).toBe("/new.ogg");
|
|
187
352
|
});
|
|
@@ -190,8 +355,24 @@ describe("media-index", () => {
|
|
|
190
355
|
describe("loadMediaIndex", () => {
|
|
191
356
|
it("loads entries from existing file", () => {
|
|
192
357
|
const stored = [
|
|
193
|
-
{
|
|
194
|
-
|
|
358
|
+
{
|
|
359
|
+
id: "load-1:1",
|
|
360
|
+
chatId: "load-1",
|
|
361
|
+
msgId: 1,
|
|
362
|
+
senderName: "Alice",
|
|
363
|
+
type: "photo",
|
|
364
|
+
filePath: "/a.jpg",
|
|
365
|
+
timestamp: Date.now(),
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
id: "load-1:2",
|
|
369
|
+
chatId: "load-1",
|
|
370
|
+
msgId: 2,
|
|
371
|
+
senderName: "Bob",
|
|
372
|
+
type: "document",
|
|
373
|
+
filePath: "/b.pdf",
|
|
374
|
+
timestamp: Date.now(),
|
|
375
|
+
},
|
|
195
376
|
];
|
|
196
377
|
existsSyncMock.mockReturnValue(true);
|
|
197
378
|
readFileSyncMock.mockReturnValue(JSON.stringify(stored));
|
|
@@ -214,8 +395,24 @@ describe("media-index", () => {
|
|
|
214
395
|
const oldTimestamp = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days ago (expired)
|
|
215
396
|
const recentTimestamp = Date.now() - 1000; // 1 second ago (fresh)
|
|
216
397
|
const stored = [
|
|
217
|
-
{
|
|
218
|
-
|
|
398
|
+
{
|
|
399
|
+
id: "purge:1",
|
|
400
|
+
chatId: "purge",
|
|
401
|
+
msgId: 1,
|
|
402
|
+
senderName: "A",
|
|
403
|
+
type: "photo",
|
|
404
|
+
filePath: "/old.jpg",
|
|
405
|
+
timestamp: oldTimestamp,
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
id: "purge:2",
|
|
409
|
+
chatId: "purge",
|
|
410
|
+
msgId: 2,
|
|
411
|
+
senderName: "A",
|
|
412
|
+
type: "photo",
|
|
413
|
+
filePath: "/new.jpg",
|
|
414
|
+
timestamp: recentTimestamp,
|
|
415
|
+
},
|
|
219
416
|
];
|
|
220
417
|
existsSyncMock.mockReturnValue(true);
|
|
221
418
|
readFileSyncMock.mockReturnValue(JSON.stringify(stored));
|
|
@@ -230,7 +427,15 @@ describe("media-index", () => {
|
|
|
230
427
|
it("deletes expired media files from disk during purge", () => {
|
|
231
428
|
const oldTimestamp = Date.now() - 8 * 24 * 60 * 60 * 1000;
|
|
232
429
|
const stored = [
|
|
233
|
-
{
|
|
430
|
+
{
|
|
431
|
+
id: "del:1",
|
|
432
|
+
chatId: "del",
|
|
433
|
+
msgId: 1,
|
|
434
|
+
senderName: "A",
|
|
435
|
+
type: "photo",
|
|
436
|
+
filePath: "/expired.jpg",
|
|
437
|
+
timestamp: oldTimestamp,
|
|
438
|
+
},
|
|
234
439
|
];
|
|
235
440
|
// existsSync: first call for STORE_FILE=true, then for filePath during purge=true
|
|
236
441
|
existsSyncMock.mockReturnValue(true);
|
|
@@ -244,7 +449,14 @@ describe("media-index", () => {
|
|
|
244
449
|
|
|
245
450
|
describe("flushMediaIndex", () => {
|
|
246
451
|
it("writes entries to disk", () => {
|
|
247
|
-
addMedia({
|
|
452
|
+
addMedia({
|
|
453
|
+
chatId: "flush-1",
|
|
454
|
+
msgId: 1,
|
|
455
|
+
senderName: "A",
|
|
456
|
+
type: "photo",
|
|
457
|
+
filePath: "/a.jpg",
|
|
458
|
+
timestamp: Date.now(),
|
|
459
|
+
});
|
|
248
460
|
|
|
249
461
|
existsSyncMock.mockReturnValue(true);
|
|
250
462
|
flushMediaIndex();
|
|
@@ -257,21 +469,91 @@ describe("media-index", () => {
|
|
|
257
469
|
});
|
|
258
470
|
|
|
259
471
|
it("creates workspace directory if it does not exist", () => {
|
|
260
|
-
addMedia({
|
|
472
|
+
addMedia({
|
|
473
|
+
chatId: "flush-2",
|
|
474
|
+
msgId: 1,
|
|
475
|
+
senderName: "A",
|
|
476
|
+
type: "photo",
|
|
477
|
+
filePath: "/a.jpg",
|
|
478
|
+
timestamp: Date.now(),
|
|
479
|
+
});
|
|
261
480
|
|
|
262
481
|
existsSyncMock.mockReturnValue(false);
|
|
263
482
|
flushMediaIndex();
|
|
264
483
|
|
|
265
|
-
expect(mkdirSyncMock).toHaveBeenCalledWith(expect.any(String), {
|
|
484
|
+
expect(mkdirSyncMock).toHaveBeenCalledWith(expect.any(String), {
|
|
485
|
+
recursive: true,
|
|
486
|
+
});
|
|
266
487
|
});
|
|
267
488
|
|
|
268
489
|
it("handles write errors gracefully", () => {
|
|
269
|
-
addMedia({
|
|
490
|
+
addMedia({
|
|
491
|
+
chatId: "flush-3",
|
|
492
|
+
msgId: 1,
|
|
493
|
+
senderName: "A",
|
|
494
|
+
type: "photo",
|
|
495
|
+
filePath: "/a.jpg",
|
|
496
|
+
timestamp: Date.now(),
|
|
497
|
+
});
|
|
270
498
|
|
|
271
499
|
existsSyncMock.mockReturnValue(true);
|
|
272
|
-
writeFileSyncMock.mockImplementationOnce(() => {
|
|
500
|
+
writeFileSyncMock.mockImplementationOnce(() => {
|
|
501
|
+
throw new Error("disk full");
|
|
502
|
+
});
|
|
273
503
|
|
|
274
504
|
expect(() => flushMediaIndex()).not.toThrow();
|
|
275
505
|
});
|
|
506
|
+
|
|
507
|
+
it("autoSave timer skips write when nothing has changed (dirty=false)", async () => {
|
|
508
|
+
vi.useFakeTimers();
|
|
509
|
+
existsSyncMock.mockReturnValue(true);
|
|
510
|
+
// Don't add any media — dirty starts false after module load
|
|
511
|
+
// Advance past the 30s autoSave interval to fire save() without dirty being set
|
|
512
|
+
await vi.advanceTimersByTimeAsync(31_000);
|
|
513
|
+
// save() should have been called by the interval but returned early (dirty=false)
|
|
514
|
+
// The key assertion: no write was performed
|
|
515
|
+
expect(writeFileSyncMock).not.toHaveBeenCalled();
|
|
516
|
+
vi.useRealTimers();
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// ── save dirty=false early return ─────────────────────────────────────────
|
|
522
|
+
|
|
523
|
+
describe("media-index — save dirty=false early return (line 46 TRUE branch)", () => {
|
|
524
|
+
it("does not write when auto-save fires with dirty=false", async () => {
|
|
525
|
+
vi.resetModules();
|
|
526
|
+
vi.useFakeTimers();
|
|
527
|
+
const wfaMock = vi.fn();
|
|
528
|
+
vi.doMock("../util/log.js", () => ({
|
|
529
|
+
log: vi.fn(),
|
|
530
|
+
logError: vi.fn(),
|
|
531
|
+
logWarn: vi.fn(),
|
|
532
|
+
logDebug: vi.fn(),
|
|
533
|
+
}));
|
|
534
|
+
vi.doMock("node:fs", () => ({
|
|
535
|
+
existsSync: vi.fn(() => false),
|
|
536
|
+
mkdirSync: vi.fn(),
|
|
537
|
+
readFileSync: vi.fn(() => "[]"),
|
|
538
|
+
unlinkSync: vi.fn(),
|
|
539
|
+
}));
|
|
540
|
+
vi.doMock("write-file-atomic", () => ({ default: { sync: wfaMock } }));
|
|
541
|
+
vi.doMock("../util/paths.js", () => ({
|
|
542
|
+
files: { media: "/fake/media.json" },
|
|
543
|
+
dirs: {},
|
|
544
|
+
}));
|
|
545
|
+
vi.doMock("../util/cleanup-registry.js", () => ({
|
|
546
|
+
registerCleanup: vi.fn(),
|
|
547
|
+
}));
|
|
548
|
+
vi.doMock("../util/watchdog.js", () => ({ recordError: vi.fn() }));
|
|
549
|
+
|
|
550
|
+
// Fresh import: dirty=false (nothing modified yet)
|
|
551
|
+
await import("../storage/media-index.js");
|
|
552
|
+
|
|
553
|
+
// Advance 31 seconds → auto-save timer fires → save() with dirty=false → early return
|
|
554
|
+
await vi.advanceTimersByTimeAsync(31_000);
|
|
555
|
+
expect(wfaMock).not.toHaveBeenCalled();
|
|
556
|
+
|
|
557
|
+
vi.useRealTimers();
|
|
276
558
|
});
|
|
277
559
|
});
|