loopwork 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/bin/loopwork +0 -0
  2. package/package.json +48 -4
  3. package/src/backends/github.ts +6 -3
  4. package/src/backends/json.ts +28 -10
  5. package/src/commands/run.ts +2 -2
  6. package/src/contracts/config.ts +3 -75
  7. package/src/contracts/index.ts +0 -6
  8. package/src/core/cli.ts +25 -16
  9. package/src/core/state.ts +10 -4
  10. package/src/core/utils.ts +10 -4
  11. package/src/monitor/index.ts +56 -34
  12. package/src/plugins/index.ts +9 -131
  13. package/examples/README.md +0 -70
  14. package/examples/basic-json-backend/.specs/tasks/TASK-001.md +0 -22
  15. package/examples/basic-json-backend/.specs/tasks/TASK-002.md +0 -23
  16. package/examples/basic-json-backend/.specs/tasks/TASK-003.md +0 -37
  17. package/examples/basic-json-backend/.specs/tasks/tasks.json +0 -19
  18. package/examples/basic-json-backend/README.md +0 -32
  19. package/examples/basic-json-backend/TESTING.md +0 -184
  20. package/examples/basic-json-backend/hello.test.ts +0 -9
  21. package/examples/basic-json-backend/hello.ts +0 -3
  22. package/examples/basic-json-backend/loopwork.config.js +0 -35
  23. package/examples/basic-json-backend/math.test.ts +0 -29
  24. package/examples/basic-json-backend/math.ts +0 -3
  25. package/examples/basic-json-backend/package.json +0 -15
  26. package/examples/basic-json-backend/quick-start.sh +0 -80
  27. package/loopwork.config.ts +0 -164
  28. package/src/plugins/asana.ts +0 -192
  29. package/src/plugins/cost-tracking.ts +0 -402
  30. package/src/plugins/discord.ts +0 -269
  31. package/src/plugins/everhour.ts +0 -335
  32. package/src/plugins/telegram/bot.ts +0 -517
  33. package/src/plugins/telegram/index.ts +0 -6
  34. package/src/plugins/telegram/notifications.ts +0 -198
  35. package/src/plugins/todoist.ts +0 -261
  36. package/test/backends.test.ts +0 -929
  37. package/test/cli.test.ts +0 -145
  38. package/test/config.test.ts +0 -90
  39. package/test/e2e.test.ts +0 -458
  40. package/test/github-tasks.test.ts +0 -191
  41. package/test/loopwork-config-types.test.ts +0 -288
  42. package/test/monitor.test.ts +0 -123
  43. package/test/plugins.test.ts +0 -1175
  44. package/test/state.test.ts +0 -295
  45. package/test/utils.test.ts +0 -60
  46. package/tsconfig.json +0 -20
@@ -1,1175 +0,0 @@
1
- import { describe, expect, test, mock, beforeEach, afterEach } from 'bun:test'
2
- import { createAsanaPlugin, AsanaClient } from '../src/plugins/asana'
3
- import { createEverhourPlugin, EverhourClient, asanaToEverhour, formatDuration } from '../src/plugins/everhour'
4
- import { createTodoistPlugin, TodoistClient } from '../src/plugins/todoist'
5
- import { createDiscordPlugin, DiscordClient } from '../src/plugins/discord'
6
- import type { PluginTask } from '../src/plugins'
7
-
8
- // Mock fetch globally
9
- const originalFetch = global.fetch
10
- let mockFetch: ReturnType<typeof mock>
11
-
12
- beforeEach(() => {
13
- mockFetch = mock(() =>
14
- Promise.resolve({
15
- ok: true,
16
- status: 200,
17
- json: () => Promise.resolve({ data: {} }),
18
- text: () => Promise.resolve(''),
19
- })
20
- )
21
- global.fetch = mockFetch as any
22
- })
23
-
24
- afterEach(() => {
25
- global.fetch = originalFetch
26
- })
27
-
28
- describe('Asana Plugin', () => {
29
- const mockTask: PluginTask = {
30
- id: 'TASK-001',
31
- title: 'Test task',
32
- metadata: { asanaGid: '123456789' },
33
- }
34
-
35
- describe('AsanaClient', () => {
36
- test('getTask makes correct API call', async () => {
37
- mockFetch.mockImplementation(() =>
38
- Promise.resolve({
39
- ok: true,
40
- json: () => Promise.resolve({ data: { gid: '123', name: 'Test' } }),
41
- })
42
- )
43
-
44
- const client = new AsanaClient('test-token')
45
- const task = await client.getTask('123')
46
-
47
- expect(mockFetch).toHaveBeenCalledWith(
48
- 'https://app.asana.com/api/1.0/tasks/123',
49
- expect.objectContaining({
50
- method: 'GET',
51
- headers: expect.objectContaining({
52
- Authorization: 'Bearer test-token',
53
- }),
54
- })
55
- )
56
- expect(task.gid).toBe('123')
57
- })
58
-
59
- test('completeTask updates task', async () => {
60
- mockFetch.mockImplementation(() =>
61
- Promise.resolve({
62
- ok: true,
63
- json: () => Promise.resolve({ data: { gid: '123', completed: true } }),
64
- })
65
- )
66
-
67
- const client = new AsanaClient('test-token')
68
- const task = await client.completeTask('123')
69
-
70
- expect(mockFetch).toHaveBeenCalledWith(
71
- 'https://app.asana.com/api/1.0/tasks/123',
72
- expect.objectContaining({
73
- method: 'PUT',
74
- body: JSON.stringify({ data: { completed: true } }),
75
- })
76
- )
77
- })
78
-
79
- test('handles API errors', async () => {
80
- mockFetch.mockImplementation(() =>
81
- Promise.resolve({
82
- ok: false,
83
- status: 401,
84
- text: () => Promise.resolve('Unauthorized'),
85
- })
86
- )
87
-
88
- const client = new AsanaClient('bad-token')
89
- await expect(client.getTask('123')).rejects.toThrow('Asana API error: 401')
90
- })
91
- })
92
-
93
- describe('createAsanaPlugin', () => {
94
- test('returns warning plugin when no credentials', () => {
95
- const plugin = createAsanaPlugin({})
96
- expect(plugin.name).toBe('asana')
97
-
98
- // Should have onConfigLoad that warns
99
- const result = plugin.onConfigLoad?.({ backend: { type: 'json' } } as any)
100
- expect(result).toBeDefined()
101
- })
102
-
103
- test('creates functional plugin with credentials', () => {
104
- const plugin = createAsanaPlugin({
105
- accessToken: 'test-token',
106
- projectId: 'project-123',
107
- })
108
-
109
- expect(plugin.name).toBe('asana')
110
- expect(plugin.onTaskStart).toBeDefined()
111
- expect(plugin.onTaskComplete).toBeDefined()
112
- expect(plugin.onTaskFailed).toBeDefined()
113
- expect(plugin.onLoopEnd).toBeDefined()
114
- })
115
-
116
- test('onTaskStart skips tasks without asanaGid', async () => {
117
- const plugin = createAsanaPlugin({
118
- accessToken: 'test-token',
119
- projectId: 'project-123',
120
- })
121
-
122
- const taskWithoutGid: PluginTask = { id: 'TASK-001', title: 'Test' }
123
- await plugin.onTaskStart?.(taskWithoutGid)
124
-
125
- // Should not have called fetch
126
- expect(mockFetch).not.toHaveBeenCalled()
127
- })
128
-
129
- test('onTaskComplete calls API with asanaGid', async () => {
130
- mockFetch.mockImplementation(() =>
131
- Promise.resolve({
132
- ok: true,
133
- json: () => Promise.resolve({ data: {} }),
134
- })
135
- )
136
-
137
- const plugin = createAsanaPlugin({
138
- accessToken: 'test-token',
139
- projectId: 'project-123',
140
- })
141
-
142
- await plugin.onTaskComplete?.(mockTask, { duration: 60 })
143
-
144
- // Should have called API twice (complete + comment)
145
- expect(mockFetch).toHaveBeenCalled()
146
- })
147
- })
148
- })
149
-
150
- describe('Everhour Plugin', () => {
151
- const mockTask: PluginTask = {
152
- id: 'TASK-001',
153
- title: 'Test task',
154
- metadata: { asanaGid: '123456789' },
155
- }
156
-
157
- describe('EverhourClient', () => {
158
- test('startTimer makes correct API call', async () => {
159
- mockFetch.mockImplementation(() =>
160
- Promise.resolve({
161
- ok: true,
162
- text: () => Promise.resolve(JSON.stringify({ status: 'active' })),
163
- })
164
- )
165
-
166
- const client = new EverhourClient('test-key')
167
- await client.startTimer('as:123')
168
-
169
- expect(mockFetch).toHaveBeenCalledWith(
170
- 'https://api.everhour.com/timers',
171
- expect.objectContaining({
172
- method: 'POST',
173
- headers: expect.objectContaining({
174
- 'X-Api-Key': 'test-key',
175
- }),
176
- })
177
- )
178
- })
179
-
180
- test('checkDailyLimit calculates correctly', async () => {
181
- mockFetch.mockImplementation(() =>
182
- Promise.resolve({
183
- ok: true,
184
- text: () => Promise.resolve(JSON.stringify([{ time: 3600 }, { time: 7200 }])), // 3 hours
185
- })
186
- )
187
-
188
- const client = new EverhourClient('test-key')
189
- const limit = await client.checkDailyLimit(8)
190
-
191
- expect(limit.hoursLogged).toBe(3)
192
- expect(limit.remaining).toBe(5)
193
- expect(limit.withinLimit).toBe(true)
194
- })
195
- })
196
-
197
- describe('helpers', () => {
198
- test('asanaToEverhour adds as: prefix', () => {
199
- expect(asanaToEverhour('123456')).toBe('as:123456')
200
- })
201
-
202
- test('formatDuration formats correctly', () => {
203
- expect(formatDuration(30)).toBe('0m')
204
- expect(formatDuration(90)).toBe('1m')
205
- expect(formatDuration(3661)).toBe('1h 1m')
206
- })
207
- })
208
-
209
- describe('createEverhourPlugin', () => {
210
- test('returns warning plugin when no API key', () => {
211
- const plugin = createEverhourPlugin({})
212
- expect(plugin.name).toBe('everhour')
213
- expect(plugin.onConfigLoad).toBeDefined()
214
- })
215
-
216
- test('derives everhourId from asanaGid', async () => {
217
- mockFetch.mockImplementation(() =>
218
- Promise.resolve({
219
- ok: true,
220
- text: () => Promise.resolve(JSON.stringify({})),
221
- })
222
- )
223
-
224
- const plugin = createEverhourPlugin({ apiKey: 'test-key' })
225
- await plugin.onTaskStart?.(mockTask)
226
-
227
- // Should have started timer with as:123456789
228
- expect(mockFetch).toHaveBeenCalledWith(
229
- 'https://api.everhour.com/timers',
230
- expect.objectContaining({
231
- body: expect.stringContaining('as:123456789'),
232
- })
233
- )
234
- })
235
-
236
- test('uses explicit everhourId over asanaGid', async () => {
237
- mockFetch.mockImplementation(() =>
238
- Promise.resolve({
239
- ok: true,
240
- text: () => Promise.resolve(JSON.stringify({})),
241
- })
242
- )
243
-
244
- const taskWithEverhourId: PluginTask = {
245
- id: 'TASK-001',
246
- title: 'Test',
247
- metadata: { asanaGid: '123', everhourId: 'custom-id' },
248
- }
249
-
250
- const plugin = createEverhourPlugin({ apiKey: 'test-key' })
251
- await plugin.onTaskStart?.(taskWithEverhourId)
252
-
253
- expect(mockFetch).toHaveBeenCalledWith(
254
- 'https://api.everhour.com/timers',
255
- expect.objectContaining({
256
- body: expect.stringContaining('custom-id'),
257
- })
258
- )
259
- })
260
- })
261
- })
262
-
263
- describe('Todoist Plugin', () => {
264
- const mockTask: PluginTask = {
265
- id: 'TASK-001',
266
- title: 'Test task',
267
- metadata: { todoistId: '999888777' },
268
- }
269
-
270
- describe('TodoistClient', () => {
271
- test('completeTask makes correct API call', async () => {
272
- mockFetch.mockImplementation(() =>
273
- Promise.resolve({ ok: true, status: 204, text: () => Promise.resolve('') })
274
- )
275
-
276
- const client = new TodoistClient('test-token')
277
- await client.completeTask('123')
278
-
279
- expect(mockFetch).toHaveBeenCalledWith(
280
- 'https://api.todoist.com/rest/v2/tasks/123/close',
281
- expect.objectContaining({
282
- method: 'POST',
283
- headers: expect.objectContaining({
284
- Authorization: 'Bearer test-token',
285
- }),
286
- })
287
- )
288
- })
289
-
290
- test('addComment makes correct API call', async () => {
291
- mockFetch.mockImplementation(() =>
292
- Promise.resolve({
293
- ok: true,
294
- json: () => Promise.resolve({ id: '1', task_id: '123', content: 'test' }),
295
- })
296
- )
297
-
298
- const client = new TodoistClient('test-token')
299
- await client.addComment('123', 'Test comment')
300
-
301
- expect(mockFetch).toHaveBeenCalledWith(
302
- 'https://api.todoist.com/rest/v2/comments',
303
- expect.objectContaining({
304
- method: 'POST',
305
- body: JSON.stringify({ task_id: '123', content: 'Test comment' }),
306
- })
307
- )
308
- })
309
- })
310
-
311
- describe('createTodoistPlugin', () => {
312
- test('returns warning plugin when no token', () => {
313
- const plugin = createTodoistPlugin({})
314
- expect(plugin.name).toBe('todoist')
315
- expect(plugin.onConfigLoad).toBeDefined()
316
- })
317
-
318
- test('skips tasks without todoistId', async () => {
319
- const plugin = createTodoistPlugin({ apiToken: 'test-token' })
320
- const taskWithoutId: PluginTask = { id: 'TASK-001', title: 'Test' }
321
-
322
- await plugin.onTaskStart?.(taskWithoutId)
323
- expect(mockFetch).not.toHaveBeenCalled()
324
- })
325
-
326
- test('onTaskComplete calls API', async () => {
327
- mockFetch.mockImplementation(() =>
328
- Promise.resolve({
329
- ok: true,
330
- status: 204,
331
- json: () => Promise.resolve({}),
332
- text: () => Promise.resolve(''),
333
- })
334
- )
335
-
336
- const plugin = createTodoistPlugin({ apiToken: 'test-token' })
337
- await plugin.onTaskComplete?.(mockTask, { duration: 45 })
338
-
339
- expect(mockFetch).toHaveBeenCalled()
340
- })
341
- })
342
- })
343
-
344
- describe('Discord Plugin', () => {
345
- const mockTask: PluginTask = {
346
- id: 'TASK-001',
347
- title: 'Test task',
348
- }
349
-
350
- describe('DiscordClient', () => {
351
- test('send makes correct API call', async () => {
352
- mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
353
-
354
- const client = new DiscordClient('https://discord.com/webhook')
355
- await client.sendText('Hello')
356
-
357
- expect(mockFetch).toHaveBeenCalledWith(
358
- 'https://discord.com/webhook',
359
- expect.objectContaining({
360
- method: 'POST',
361
- body: expect.stringContaining('Hello'),
362
- })
363
- )
364
- })
365
-
366
- test('sendEmbed includes embed structure', async () => {
367
- mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
368
-
369
- const client = new DiscordClient('https://discord.com/webhook')
370
- await client.sendEmbed({
371
- title: 'Test',
372
- description: 'Description',
373
- color: 0x00ff00,
374
- })
375
-
376
- expect(mockFetch).toHaveBeenCalledWith(
377
- 'https://discord.com/webhook',
378
- expect.objectContaining({
379
- body: expect.stringContaining('"embeds"'),
380
- })
381
- )
382
- })
383
-
384
- test('handles API errors', async () => {
385
- mockFetch.mockImplementation(() =>
386
- Promise.resolve({
387
- ok: false,
388
- status: 400,
389
- text: () => Promise.resolve('Bad Request'),
390
- })
391
- )
392
-
393
- const client = new DiscordClient('https://discord.com/webhook')
394
- await expect(client.sendText('test')).rejects.toThrow('Discord webhook error')
395
- })
396
- })
397
-
398
- describe('createDiscordPlugin', () => {
399
- test('returns warning plugin when no webhook URL', () => {
400
- const plugin = createDiscordPlugin({})
401
- expect(plugin.name).toBe('discord')
402
- expect(plugin.onConfigLoad).toBeDefined()
403
- })
404
-
405
- test('respects notification settings', async () => {
406
- mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
407
-
408
- const plugin = createDiscordPlugin({
409
- webhookUrl: 'https://discord.com/webhook',
410
- notifyOnStart: false,
411
- notifyOnComplete: false,
412
- })
413
-
414
- await plugin.onTaskStart?.(mockTask)
415
- await plugin.onTaskComplete?.(mockTask, { duration: 30 })
416
-
417
- expect(mockFetch).not.toHaveBeenCalled()
418
- })
419
-
420
- test('sends notification on task failure', async () => {
421
- mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
422
-
423
- const plugin = createDiscordPlugin({
424
- webhookUrl: 'https://discord.com/webhook',
425
- notifyOnFail: true,
426
- })
427
-
428
- await plugin.onTaskFailed?.(mockTask, 'Test error')
429
-
430
- expect(mockFetch).toHaveBeenCalled()
431
- })
432
-
433
- test('includes mention on failure', async () => {
434
- mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
435
-
436
- const plugin = createDiscordPlugin({
437
- webhookUrl: 'https://discord.com/webhook',
438
- mentionOnFail: '<@&123456>',
439
- })
440
-
441
- await plugin.onTaskFailed?.(mockTask, 'Test error')
442
-
443
- expect(mockFetch).toHaveBeenCalledWith(
444
- 'https://discord.com/webhook',
445
- expect.objectContaining({
446
- body: expect.stringContaining('<@&123456>'),
447
- })
448
- )
449
- })
450
-
451
- test('sends loop end summary', async () => {
452
- mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
453
-
454
- const plugin = createDiscordPlugin({
455
- webhookUrl: 'https://discord.com/webhook',
456
- notifyOnLoopEnd: true,
457
- })
458
-
459
- await plugin.onLoopEnd?.({ completed: 5, failed: 1, duration: 300 })
460
-
461
- expect(mockFetch).toHaveBeenCalled()
462
- })
463
- })
464
- })
465
-
466
- // Additional tests for better coverage
467
-
468
- describe('Asana Plugin - Additional Coverage', () => {
469
- describe('AsanaClient additional methods', () => {
470
- test('createTask makes correct API call', async () => {
471
- mockFetch.mockImplementation(() =>
472
- Promise.resolve({
473
- ok: true,
474
- json: () => Promise.resolve({ data: { gid: 'new-123', name: 'New Task' } }),
475
- })
476
- )
477
-
478
- const client = new AsanaClient('test-token')
479
- const task = await client.createTask('project-123', 'New Task', 'Description')
480
-
481
- expect(mockFetch).toHaveBeenCalledWith(
482
- 'https://app.asana.com/api/1.0/tasks',
483
- expect.objectContaining({
484
- method: 'POST',
485
- body: expect.stringContaining('New Task'),
486
- })
487
- )
488
- })
489
-
490
- test('addComment makes correct API call', async () => {
491
- mockFetch.mockImplementation(() =>
492
- Promise.resolve({
493
- ok: true,
494
- json: () => Promise.resolve({ data: {} }),
495
- })
496
- )
497
-
498
- const client = new AsanaClient('test-token')
499
- await client.addComment('123', 'Test comment')
500
-
501
- expect(mockFetch).toHaveBeenCalledWith(
502
- 'https://app.asana.com/api/1.0/tasks/123/stories',
503
- expect.objectContaining({
504
- method: 'POST',
505
- body: expect.stringContaining('Test comment'),
506
- })
507
- )
508
- })
509
-
510
- test('getProjectTasks makes correct API call', async () => {
511
- mockFetch.mockImplementation(() =>
512
- Promise.resolve({
513
- ok: true,
514
- json: () => Promise.resolve({ data: [{ gid: '1' }, { gid: '2' }] }),
515
- })
516
- )
517
-
518
- const client = new AsanaClient('test-token')
519
- const tasks = await client.getProjectTasks('project-123')
520
-
521
- expect(mockFetch).toHaveBeenCalledWith(
522
- expect.stringContaining('/projects/project-123/tasks'),
523
- expect.anything()
524
- )
525
- })
526
-
527
- test('updateTask makes correct API call', async () => {
528
- mockFetch.mockImplementation(() =>
529
- Promise.resolve({
530
- ok: true,
531
- json: () => Promise.resolve({ data: { gid: '123', name: 'Updated' } }),
532
- })
533
- )
534
-
535
- const client = new AsanaClient('test-token')
536
- await client.updateTask('123', { name: 'Updated' })
537
-
538
- expect(mockFetch).toHaveBeenCalledWith(
539
- 'https://app.asana.com/api/1.0/tasks/123',
540
- expect.objectContaining({
541
- method: 'PUT',
542
- })
543
- )
544
- })
545
- })
546
-
547
- describe('createAsanaPlugin additional coverage', () => {
548
- test('onTaskStart adds comment', async () => {
549
- mockFetch.mockImplementation(() =>
550
- Promise.resolve({
551
- ok: true,
552
- json: () => Promise.resolve({ data: {} }),
553
- })
554
- )
555
-
556
- const plugin = createAsanaPlugin({
557
- accessToken: 'test-token',
558
- projectId: 'project-123',
559
- })
560
-
561
- const task: PluginTask = {
562
- id: 'TASK-001',
563
- title: 'Test',
564
- metadata: { asanaGid: '123456' },
565
- }
566
-
567
- await plugin.onTaskStart?.(task)
568
-
569
- expect(mockFetch).toHaveBeenCalledWith(
570
- 'https://app.asana.com/api/1.0/tasks/123456/stories',
571
- expect.anything()
572
- )
573
- })
574
-
575
- test('onTaskFailed adds comment', async () => {
576
- mockFetch.mockImplementation(() =>
577
- Promise.resolve({
578
- ok: true,
579
- json: () => Promise.resolve({ data: {} }),
580
- })
581
- )
582
-
583
- const plugin = createAsanaPlugin({
584
- accessToken: 'test-token',
585
- projectId: 'project-123',
586
- })
587
-
588
- const task: PluginTask = {
589
- id: 'TASK-001',
590
- title: 'Test',
591
- metadata: { asanaGid: '123456' },
592
- }
593
-
594
- await plugin.onTaskFailed?.(task, 'Error message')
595
-
596
- expect(mockFetch).toHaveBeenCalled()
597
- })
598
-
599
- test('onLoopEnd logs summary', async () => {
600
- const plugin = createAsanaPlugin({
601
- accessToken: 'test-token',
602
- projectId: 'project-123',
603
- })
604
-
605
- // Should not throw
606
- await plugin.onLoopEnd?.({ completed: 5, failed: 1, duration: 300 })
607
- })
608
- })
609
- })
610
-
611
- describe('Everhour Plugin - Additional Coverage', () => {
612
- describe('EverhourClient additional methods', () => {
613
- test('stopTimer makes correct API call', async () => {
614
- mockFetch.mockImplementation(() =>
615
- Promise.resolve({
616
- ok: true,
617
- text: () => Promise.resolve(JSON.stringify({ status: 'stopped' })),
618
- })
619
- )
620
-
621
- const client = new EverhourClient('test-key')
622
- await client.stopTimer()
623
-
624
- expect(mockFetch).toHaveBeenCalledWith(
625
- 'https://api.everhour.com/timers/current',
626
- expect.objectContaining({
627
- method: 'DELETE',
628
- })
629
- )
630
- })
631
-
632
- test('addTime makes correct API call', async () => {
633
- mockFetch.mockImplementation(() =>
634
- Promise.resolve({
635
- ok: true,
636
- text: () => Promise.resolve(JSON.stringify({ id: 1, time: 3600 })),
637
- })
638
- )
639
-
640
- const client = new EverhourClient('test-key')
641
- await client.addTime('as:123', 3600, '2024-01-15')
642
-
643
- expect(mockFetch).toHaveBeenCalledWith(
644
- 'https://api.everhour.com/tasks/as:123/time',
645
- expect.objectContaining({
646
- method: 'POST',
647
- body: expect.stringContaining('3600'),
648
- })
649
- )
650
- })
651
-
652
- test('getTaskTime makes correct API call', async () => {
653
- mockFetch.mockImplementation(() =>
654
- Promise.resolve({
655
- ok: true,
656
- text: () => Promise.resolve(JSON.stringify({ id: 'as:123', time: { total: 7200 } })),
657
- })
658
- )
659
-
660
- const client = new EverhourClient('test-key')
661
- const task = await client.getTaskTime('as:123')
662
-
663
- expect(mockFetch).toHaveBeenCalledWith(
664
- 'https://api.everhour.com/tasks/as:123',
665
- expect.anything()
666
- )
667
- })
668
-
669
- test('getCurrentTimer makes correct API call', async () => {
670
- mockFetch.mockImplementation(() =>
671
- Promise.resolve({
672
- ok: true,
673
- text: () => Promise.resolve(JSON.stringify({ status: 'active', duration: 1800 })),
674
- })
675
- )
676
-
677
- const client = new EverhourClient('test-key')
678
- const timer = await client.getCurrentTimer()
679
-
680
- expect(mockFetch).toHaveBeenCalledWith(
681
- 'https://api.everhour.com/timers/current',
682
- expect.anything()
683
- )
684
- })
685
-
686
- test('getCurrentTimer returns null on error', async () => {
687
- mockFetch.mockImplementation(() =>
688
- Promise.resolve({
689
- ok: false,
690
- status: 404,
691
- text: () => Promise.resolve('Not found'),
692
- })
693
- )
694
-
695
- const client = new EverhourClient('test-key')
696
- const timer = await client.getCurrentTimer()
697
-
698
- expect(timer).toBeNull()
699
- })
700
-
701
- test('getTodayTotal calculates correctly', async () => {
702
- mockFetch.mockImplementation(() =>
703
- Promise.resolve({
704
- ok: true,
705
- text: () => Promise.resolve(JSON.stringify([{ time: 1800 }, { time: 3600 }])),
706
- })
707
- )
708
-
709
- const client = new EverhourClient('test-key')
710
- const total = await client.getTodayTotal()
711
-
712
- expect(total).toBe(5400)
713
- })
714
-
715
- test('getMe makes correct API call', async () => {
716
- mockFetch.mockImplementation(() =>
717
- Promise.resolve({
718
- ok: true,
719
- text: () => Promise.resolve(JSON.stringify({ id: 1, name: 'Test', email: 'test@test.com' })),
720
- })
721
- )
722
-
723
- const client = new EverhourClient('test-key')
724
- const user = await client.getMe()
725
-
726
- expect(mockFetch).toHaveBeenCalledWith(
727
- 'https://api.everhour.com/users/me',
728
- expect.anything()
729
- )
730
- expect(user.email).toBe('test@test.com')
731
- })
732
-
733
- test('handles API errors', async () => {
734
- mockFetch.mockImplementation(() =>
735
- Promise.resolve({
736
- ok: false,
737
- status: 401,
738
- text: () => Promise.resolve('Unauthorized'),
739
- })
740
- )
741
-
742
- const client = new EverhourClient('bad-key')
743
- await expect(client.startTimer('as:123')).rejects.toThrow('Everhour API error')
744
- })
745
- })
746
-
747
- describe('createEverhourPlugin additional coverage', () => {
748
- test('onLoopStart checks daily limit', async () => {
749
- mockFetch.mockImplementation(() =>
750
- Promise.resolve({
751
- ok: true,
752
- text: () => Promise.resolve(JSON.stringify([{ time: 28800 }])), // 8 hours
753
- })
754
- )
755
-
756
- const plugin = createEverhourPlugin({ apiKey: 'test-key' })
757
- await plugin.onLoopStart?.('test-namespace')
758
-
759
- expect(mockFetch).toHaveBeenCalled()
760
- })
761
-
762
- test('onTaskComplete stops timer', async () => {
763
- mockFetch.mockImplementation(() =>
764
- Promise.resolve({
765
- ok: true,
766
- text: () => Promise.resolve(JSON.stringify({})),
767
- })
768
- )
769
-
770
- const plugin = createEverhourPlugin({ apiKey: 'test-key' })
771
- const task: PluginTask = {
772
- id: 'TASK-001',
773
- title: 'Test',
774
- metadata: { asanaGid: '123' },
775
- }
776
-
777
- // Start task first to register timer
778
- await plugin.onTaskStart?.(task)
779
-
780
- // Then complete it
781
- await plugin.onTaskComplete?.(task, { duration: 60 })
782
-
783
- // Should have called stop timer
784
- expect(mockFetch).toHaveBeenCalled()
785
- })
786
-
787
- test('onTaskFailed stops timer', async () => {
788
- mockFetch.mockImplementation(() =>
789
- Promise.resolve({
790
- ok: true,
791
- text: () => Promise.resolve(JSON.stringify({})),
792
- })
793
- )
794
-
795
- const plugin = createEverhourPlugin({ apiKey: 'test-key' })
796
- const task: PluginTask = {
797
- id: 'TASK-002',
798
- title: 'Test',
799
- metadata: { everhourId: 'eh:456' },
800
- }
801
-
802
- await plugin.onTaskStart?.(task)
803
- await plugin.onTaskFailed?.(task, 'Error')
804
-
805
- expect(mockFetch).toHaveBeenCalled()
806
- })
807
-
808
- test('onLoopEnd reports summary', async () => {
809
- mockFetch.mockImplementation(() =>
810
- Promise.resolve({
811
- ok: true,
812
- text: () => Promise.resolve(JSON.stringify([{ time: 3600 }])),
813
- })
814
- )
815
-
816
- const plugin = createEverhourPlugin({ apiKey: 'test-key' })
817
- await plugin.onLoopEnd?.({ completed: 5, failed: 1, duration: 300 })
818
-
819
- expect(mockFetch).toHaveBeenCalled()
820
- })
821
-
822
- test('skips tasks without everhour ID', async () => {
823
- const plugin = createEverhourPlugin({ apiKey: 'test-key' })
824
- const task: PluginTask = { id: 'TASK-001', title: 'Test' }
825
-
826
- await plugin.onTaskStart?.(task)
827
-
828
- // Should not have called timer start (only daily limit check in onLoopStart)
829
- const timerCalls = mockFetch.mock.calls.filter(
830
- (call: any) => call[0].includes('/timers') && !call[0].includes('current')
831
- )
832
- expect(timerCalls.length).toBe(0)
833
- })
834
- })
835
- })
836
-
837
- describe('Todoist Plugin - Additional Coverage', () => {
838
- describe('TodoistClient additional methods', () => {
839
- test('getTask makes correct API call', async () => {
840
- mockFetch.mockImplementation(() =>
841
- Promise.resolve({
842
- ok: true,
843
- json: () => Promise.resolve({ id: '123', content: 'Test task' }),
844
- })
845
- )
846
-
847
- const client = new TodoistClient('test-token')
848
- const task = await client.getTask('123')
849
-
850
- expect(mockFetch).toHaveBeenCalledWith(
851
- 'https://api.todoist.com/rest/v2/tasks/123',
852
- expect.anything()
853
- )
854
- expect(task.content).toBe('Test task')
855
- })
856
-
857
- test('createTask makes correct API call', async () => {
858
- mockFetch.mockImplementation(() =>
859
- Promise.resolve({
860
- ok: true,
861
- json: () => Promise.resolve({ id: 'new-123', content: 'New task' }),
862
- })
863
- )
864
-
865
- const client = new TodoistClient('test-token')
866
- const task = await client.createTask('New task', {
867
- description: 'Description',
868
- projectId: 'project-123',
869
- priority: 2,
870
- })
871
-
872
- expect(mockFetch).toHaveBeenCalledWith(
873
- 'https://api.todoist.com/rest/v2/tasks',
874
- expect.objectContaining({
875
- method: 'POST',
876
- body: expect.stringContaining('New task'),
877
- })
878
- )
879
- })
880
-
881
- test('updateTask makes correct API call', async () => {
882
- mockFetch.mockImplementation(() =>
883
- Promise.resolve({
884
- ok: true,
885
- json: () => Promise.resolve({ id: '123', content: 'Updated' }),
886
- })
887
- )
888
-
889
- const client = new TodoistClient('test-token')
890
- await client.updateTask('123', { content: 'Updated' })
891
-
892
- expect(mockFetch).toHaveBeenCalledWith(
893
- 'https://api.todoist.com/rest/v2/tasks/123',
894
- expect.objectContaining({
895
- method: 'POST',
896
- })
897
- )
898
- })
899
-
900
- test('reopenTask makes correct API call', async () => {
901
- mockFetch.mockImplementation(() =>
902
- Promise.resolve({ ok: true, status: 204, text: () => Promise.resolve('') })
903
- )
904
-
905
- const client = new TodoistClient('test-token')
906
- await client.reopenTask('123')
907
-
908
- expect(mockFetch).toHaveBeenCalledWith(
909
- 'https://api.todoist.com/rest/v2/tasks/123/reopen',
910
- expect.objectContaining({
911
- method: 'POST',
912
- })
913
- )
914
- })
915
-
916
- test('deleteTask makes correct API call', async () => {
917
- mockFetch.mockImplementation(() =>
918
- Promise.resolve({ ok: true, status: 204, text: () => Promise.resolve('') })
919
- )
920
-
921
- const client = new TodoistClient('test-token')
922
- await client.deleteTask('123')
923
-
924
- expect(mockFetch).toHaveBeenCalledWith(
925
- 'https://api.todoist.com/rest/v2/tasks/123',
926
- expect.objectContaining({
927
- method: 'DELETE',
928
- })
929
- )
930
- })
931
-
932
- test('getProjectTasks makes correct API call', async () => {
933
- mockFetch.mockImplementation(() =>
934
- Promise.resolve({
935
- ok: true,
936
- json: () => Promise.resolve([{ id: '1' }, { id: '2' }]),
937
- })
938
- )
939
-
940
- const client = new TodoistClient('test-token')
941
- const tasks = await client.getProjectTasks('project-123')
942
-
943
- expect(mockFetch).toHaveBeenCalledWith(
944
- 'https://api.todoist.com/rest/v2/tasks?project_id=project-123',
945
- expect.anything()
946
- )
947
- expect(tasks).toHaveLength(2)
948
- })
949
-
950
- test('getComments makes correct API call', async () => {
951
- mockFetch.mockImplementation(() =>
952
- Promise.resolve({
953
- ok: true,
954
- json: () => Promise.resolve([{ id: '1', content: 'Comment' }]),
955
- })
956
- )
957
-
958
- const client = new TodoistClient('test-token')
959
- const comments = await client.getComments('123')
960
-
961
- expect(mockFetch).toHaveBeenCalledWith(
962
- 'https://api.todoist.com/rest/v2/comments?task_id=123',
963
- expect.anything()
964
- )
965
- })
966
-
967
- test('getProjects makes correct API call', async () => {
968
- mockFetch.mockImplementation(() =>
969
- Promise.resolve({
970
- ok: true,
971
- json: () => Promise.resolve([{ id: '1', name: 'Project' }]),
972
- })
973
- )
974
-
975
- const client = new TodoistClient('test-token')
976
- const projects = await client.getProjects()
977
-
978
- expect(mockFetch).toHaveBeenCalledWith(
979
- 'https://api.todoist.com/rest/v2/projects',
980
- expect.anything()
981
- )
982
- })
983
-
984
- test('handles API errors', async () => {
985
- mockFetch.mockImplementation(() =>
986
- Promise.resolve({
987
- ok: false,
988
- status: 403,
989
- text: () => Promise.resolve('Forbidden'),
990
- })
991
- )
992
-
993
- const client = new TodoistClient('bad-token')
994
- await expect(client.getTask('123')).rejects.toThrow('Todoist API error')
995
- })
996
- })
997
-
998
- describe('createTodoistPlugin additional coverage', () => {
999
- test('onTaskStart adds comment', async () => {
1000
- mockFetch.mockImplementation(() =>
1001
- Promise.resolve({
1002
- ok: true,
1003
- json: () => Promise.resolve({ id: '1' }),
1004
- })
1005
- )
1006
-
1007
- const plugin = createTodoistPlugin({ apiToken: 'test-token' })
1008
- const task: PluginTask = {
1009
- id: 'TASK-001',
1010
- title: 'Test',
1011
- metadata: { todoistId: '123' },
1012
- }
1013
-
1014
- await plugin.onTaskStart?.(task)
1015
-
1016
- expect(mockFetch).toHaveBeenCalledWith(
1017
- 'https://api.todoist.com/rest/v2/comments',
1018
- expect.anything()
1019
- )
1020
- })
1021
-
1022
- test('onTaskFailed adds comment', async () => {
1023
- mockFetch.mockImplementation(() =>
1024
- Promise.resolve({
1025
- ok: true,
1026
- json: () => Promise.resolve({ id: '1' }),
1027
- })
1028
- )
1029
-
1030
- const plugin = createTodoistPlugin({ apiToken: 'test-token' })
1031
- const task: PluginTask = {
1032
- id: 'TASK-001',
1033
- title: 'Test',
1034
- metadata: { todoistId: '123' },
1035
- }
1036
-
1037
- await plugin.onTaskFailed?.(task, 'Error message')
1038
-
1039
- expect(mockFetch).toHaveBeenCalled()
1040
- })
1041
-
1042
- test('onLoopEnd logs summary', async () => {
1043
- const plugin = createTodoistPlugin({ apiToken: 'test-token' })
1044
- await plugin.onLoopEnd?.({ completed: 5, failed: 1, duration: 300 })
1045
- // Should not throw
1046
- })
1047
-
1048
- test('respects addComments=false', async () => {
1049
- const plugin = createTodoistPlugin({
1050
- apiToken: 'test-token',
1051
- addComments: false,
1052
- })
1053
- const task: PluginTask = {
1054
- id: 'TASK-001',
1055
- title: 'Test',
1056
- metadata: { todoistId: '123' },
1057
- }
1058
-
1059
- await plugin.onTaskStart?.(task)
1060
-
1061
- // Should not have added comment
1062
- expect(mockFetch).not.toHaveBeenCalled()
1063
- })
1064
- })
1065
- })
1066
-
1067
- describe('Discord Plugin - Additional Coverage', () => {
1068
- describe('DiscordClient additional methods', () => {
1069
- test('notifyTaskStart sends embed', async () => {
1070
- mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
1071
-
1072
- const client = new DiscordClient('https://discord.com/webhook')
1073
- await client.notifyTaskStart({ id: 'TASK-001', title: 'Test' })
1074
-
1075
- expect(mockFetch).toHaveBeenCalledWith(
1076
- 'https://discord.com/webhook',
1077
- expect.objectContaining({
1078
- body: expect.stringContaining('Task Started'),
1079
- })
1080
- )
1081
- })
1082
-
1083
- test('notifyTaskComplete sends embed', async () => {
1084
- mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
1085
-
1086
- const client = new DiscordClient('https://discord.com/webhook')
1087
- await client.notifyTaskComplete({ id: 'TASK-001', title: 'Test' }, 120)
1088
-
1089
- expect(mockFetch).toHaveBeenCalledWith(
1090
- 'https://discord.com/webhook',
1091
- expect.objectContaining({
1092
- body: expect.stringContaining('Task Completed'),
1093
- })
1094
- )
1095
- })
1096
-
1097
- test('notifyTaskFailed sends embed with mention', async () => {
1098
- mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
1099
-
1100
- const client = new DiscordClient('https://discord.com/webhook')
1101
- await client.notifyTaskFailed({ id: 'TASK-001', title: 'Test' }, 'Error', '<@123>')
1102
-
1103
- expect(mockFetch).toHaveBeenCalledWith(
1104
- 'https://discord.com/webhook',
1105
- expect.objectContaining({
1106
- body: expect.stringContaining('<@123>'),
1107
- })
1108
- )
1109
- })
1110
-
1111
- test('notifyLoopEnd sends summary embed', async () => {
1112
- mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
1113
-
1114
- const client = new DiscordClient('https://discord.com/webhook')
1115
- await client.notifyLoopEnd({ completed: 10, failed: 2, duration: 600 })
1116
-
1117
- expect(mockFetch).toHaveBeenCalledWith(
1118
- 'https://discord.com/webhook',
1119
- expect.objectContaining({
1120
- body: expect.stringContaining('Loop Summary'),
1121
- })
1122
- )
1123
- })
1124
-
1125
- test('uses custom username and avatar', async () => {
1126
- mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
1127
-
1128
- const client = new DiscordClient('https://discord.com/webhook', {
1129
- username: 'Custom Bot',
1130
- avatarUrl: 'https://example.com/avatar.png',
1131
- })
1132
- await client.sendText('Hello')
1133
-
1134
- expect(mockFetch).toHaveBeenCalledWith(
1135
- 'https://discord.com/webhook',
1136
- expect.objectContaining({
1137
- body: expect.stringContaining('Custom Bot'),
1138
- })
1139
- )
1140
- })
1141
- })
1142
-
1143
- describe('createDiscordPlugin additional coverage', () => {
1144
- test('sends notification on task start when enabled', async () => {
1145
- mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
1146
-
1147
- const plugin = createDiscordPlugin({
1148
- webhookUrl: 'https://discord.com/webhook',
1149
- notifyOnStart: true,
1150
- })
1151
-
1152
- await plugin.onTaskStart?.({ id: 'TASK-001', title: 'Test' })
1153
-
1154
- expect(mockFetch).toHaveBeenCalled()
1155
- })
1156
-
1157
- test('handles API errors gracefully', async () => {
1158
- mockFetch.mockImplementation(() =>
1159
- Promise.resolve({
1160
- ok: false,
1161
- status: 500,
1162
- text: () => Promise.resolve('Server Error'),
1163
- })
1164
- )
1165
-
1166
- const plugin = createDiscordPlugin({
1167
- webhookUrl: 'https://discord.com/webhook',
1168
- notifyOnComplete: true,
1169
- })
1170
-
1171
- // Should not throw, just log warning
1172
- await plugin.onTaskComplete?.({ id: 'TASK-001', title: 'Test' }, { duration: 30 })
1173
- })
1174
- })
1175
- })