@stina/extension-api 0.21.0 → 0.22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stina/extension-api",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,525 @@
1
+ /**
2
+ * WorkerBackgroundTaskManager Tests
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
6
+ import { WorkerBackgroundTaskManager, type WorkerBackgroundTaskManagerOptions } from './background.js'
7
+ import type { BackgroundTaskConfig, BackgroundTaskContext } from './types.js'
8
+
9
+ describe('WorkerBackgroundTaskManager', () => {
10
+ let manager: WorkerBackgroundTaskManager
11
+ let sendTaskRegistered: ReturnType<typeof vi.fn>
12
+ let sendTaskStatus: ReturnType<typeof vi.fn>
13
+ let sendHealthReport: ReturnType<typeof vi.fn>
14
+ let createLogAPI: ReturnType<typeof vi.fn>
15
+
16
+ beforeEach(() => {
17
+ sendTaskRegistered = vi.fn()
18
+ sendTaskStatus = vi.fn()
19
+ sendHealthReport = vi.fn()
20
+ createLogAPI = vi.fn(() => ({
21
+ debug: vi.fn(),
22
+ info: vi.fn(),
23
+ warn: vi.fn(),
24
+ error: vi.fn(),
25
+ }))
26
+
27
+ const options: WorkerBackgroundTaskManagerOptions = {
28
+ extensionId: 'test-extension',
29
+ extensionVersion: '1.0.0',
30
+ storagePath: '/fake/path',
31
+ sendTaskRegistered,
32
+ sendTaskStatus,
33
+ sendHealthReport,
34
+ createLogAPI,
35
+ }
36
+
37
+ manager = new WorkerBackgroundTaskManager(options)
38
+ })
39
+
40
+ describe('start', () => {
41
+ it('should register a task and notify host', async () => {
42
+ const config: BackgroundTaskConfig = {
43
+ id: 'task-1',
44
+ name: 'Test Task',
45
+ userId: 'user-1',
46
+ restartPolicy: { type: 'never' },
47
+ }
48
+ const callback = vi.fn()
49
+
50
+ await manager.start(config, callback)
51
+
52
+ expect(manager.hasTask('task-1')).toBe(true)
53
+ expect(sendTaskRegistered).toHaveBeenCalledWith(
54
+ 'task-1',
55
+ 'Test Task',
56
+ 'user-1',
57
+ { type: 'never' },
58
+ undefined
59
+ )
60
+ })
61
+
62
+ it('should throw error if task already exists', async () => {
63
+ const config: BackgroundTaskConfig = {
64
+ id: 'task-1',
65
+ name: 'Test Task',
66
+ userId: 'user-1',
67
+ restartPolicy: { type: 'never' },
68
+ }
69
+
70
+ await manager.start(config, vi.fn())
71
+
72
+ await expect(manager.start(config, vi.fn())).rejects.toThrow(
73
+ "Background task with id 'task-1' is already registered"
74
+ )
75
+ })
76
+
77
+ it('should return a disposable that stops the task', async () => {
78
+ const config: BackgroundTaskConfig = {
79
+ id: 'task-1',
80
+ name: 'Test Task',
81
+ userId: 'user-1',
82
+ restartPolicy: { type: 'never' },
83
+ }
84
+
85
+ const disposable = await manager.start(config, vi.fn())
86
+ disposable.dispose()
87
+
88
+ expect(sendTaskStatus).toHaveBeenCalledWith('task-1', 'stopped')
89
+ })
90
+ })
91
+
92
+ describe('stop', () => {
93
+ it('should abort running task and notify host', async () => {
94
+ const config: BackgroundTaskConfig = {
95
+ id: 'task-1',
96
+ name: 'Test Task',
97
+ userId: 'user-1',
98
+ restartPolicy: { type: 'never' },
99
+ }
100
+ const callback = vi.fn(async ({ signal }) => {
101
+ // Simulate long-running task
102
+ return new Promise<void>((resolve) => {
103
+ const timeout = setTimeout(resolve, 10000)
104
+ signal.addEventListener('abort', () => {
105
+ clearTimeout(timeout)
106
+ resolve()
107
+ })
108
+ })
109
+ })
110
+
111
+ await manager.start(config, callback)
112
+ sendTaskStatus.mockClear()
113
+
114
+ // Start the task
115
+ const handlePromise = manager.handleStart('task-1')
116
+
117
+ // Wait a bit for task to start
118
+ await new Promise((resolve) => setTimeout(resolve, 10))
119
+
120
+ // Stop it
121
+ manager.stop('task-1')
122
+
123
+ expect(sendTaskStatus).toHaveBeenCalledWith('task-1', 'stopped')
124
+
125
+ // Wait for handleStart to complete
126
+ await handlePromise
127
+ })
128
+
129
+ it('should do nothing if task does not exist', () => {
130
+ manager.stop('non-existent')
131
+
132
+ expect(sendTaskStatus).not.toHaveBeenCalled()
133
+ })
134
+ })
135
+
136
+ describe('handleStart', () => {
137
+ it('should execute task callback and notify host', async () => {
138
+ const config: BackgroundTaskConfig = {
139
+ id: 'task-1',
140
+ name: 'Test Task',
141
+ userId: 'user-1',
142
+ restartPolicy: { type: 'never' },
143
+ }
144
+ const callback = vi.fn(async () => {
145
+ // Task completes successfully
146
+ })
147
+
148
+ await manager.start(config, callback)
149
+ sendTaskStatus.mockClear()
150
+
151
+ await manager.handleStart('task-1')
152
+
153
+ expect(callback).toHaveBeenCalledTimes(1)
154
+ expect(sendTaskStatus).toHaveBeenCalledWith('task-1', 'running')
155
+ expect(sendTaskStatus).toHaveBeenCalledWith('task-1', 'stopped')
156
+ })
157
+
158
+ it('should abort previous execution if already running', async () => {
159
+ const config: BackgroundTaskConfig = {
160
+ id: 'task-1',
161
+ name: 'Test Task',
162
+ userId: 'user-1',
163
+ restartPolicy: { type: 'never' },
164
+ }
165
+
166
+ let firstAborted = false
167
+ let secondCompleted = false
168
+
169
+ const callback = vi.fn(async ({ signal }) => {
170
+ return new Promise<void>((resolve) => {
171
+ const timeout = setTimeout(() => {
172
+ secondCompleted = true
173
+ resolve()
174
+ }, 100)
175
+
176
+ signal.addEventListener('abort', () => {
177
+ clearTimeout(timeout)
178
+ firstAborted = true
179
+ resolve()
180
+ })
181
+ })
182
+ })
183
+
184
+ await manager.start(config, callback)
185
+
186
+ // Start first execution
187
+ const firstPromise = manager.handleStart('task-1')
188
+
189
+ // Start second execution before first completes - should abort first
190
+ await new Promise((resolve) => setTimeout(resolve, 10))
191
+ const secondPromise = manager.handleStart('task-1')
192
+
193
+ await Promise.all([firstPromise, secondPromise])
194
+
195
+ expect(callback).toHaveBeenCalledTimes(2)
196
+ expect(firstAborted).toBe(true)
197
+ expect(secondCompleted).toBe(true)
198
+ })
199
+
200
+ it('should handle task callback errors', async () => {
201
+ const config: BackgroundTaskConfig = {
202
+ id: 'task-1',
203
+ name: 'Test Task',
204
+ userId: 'user-1',
205
+ restartPolicy: { type: 'never' },
206
+ }
207
+ const callback = vi.fn(async () => {
208
+ throw new Error('Task failed')
209
+ })
210
+
211
+ await manager.start(config, callback)
212
+ sendTaskStatus.mockClear()
213
+
214
+ await manager.handleStart('task-1')
215
+
216
+ expect(sendTaskStatus).toHaveBeenCalledWith('task-1', 'running')
217
+ expect(sendTaskStatus).toHaveBeenCalledWith('task-1', 'failed', 'Task failed')
218
+ })
219
+
220
+ it('should detect aborted task', async () => {
221
+ const config: BackgroundTaskConfig = {
222
+ id: 'task-1',
223
+ name: 'Test Task',
224
+ userId: 'user-1',
225
+ restartPolicy: { type: 'never' },
226
+ }
227
+
228
+ let wasAborted = false
229
+
230
+ const callback = vi.fn(async ({ signal }) => {
231
+ return new Promise<void>((resolve) => {
232
+ const timeout = setTimeout(resolve, 100)
233
+ signal.addEventListener('abort', () => {
234
+ clearTimeout(timeout)
235
+ wasAborted = true
236
+ resolve()
237
+ })
238
+ })
239
+ })
240
+
241
+ await manager.start(config, callback)
242
+ sendTaskStatus.mockClear()
243
+
244
+ const handlePromise = manager.handleStart('task-1')
245
+
246
+ // Abort the task
247
+ await new Promise((resolve) => setTimeout(resolve, 10))
248
+ manager.stop('task-1')
249
+
250
+ await handlePromise
251
+
252
+ expect(wasAborted).toBe(true)
253
+ expect(sendTaskStatus).toHaveBeenCalledWith('task-1', 'stopped')
254
+ })
255
+
256
+ it('should do nothing if task does not exist', async () => {
257
+ await manager.handleStart('non-existent')
258
+
259
+ expect(sendTaskStatus).not.toHaveBeenCalled()
260
+ })
261
+
262
+ it('should provide correct execution context', async () => {
263
+ const config: BackgroundTaskConfig = {
264
+ id: 'task-1',
265
+ name: 'Test Task',
266
+ userId: 'user-1',
267
+ restartPolicy: { type: 'never' },
268
+ }
269
+
270
+ let receivedContext: BackgroundTaskContext | null = null
271
+
272
+ const callback = vi.fn(async (context) => {
273
+ receivedContext = context
274
+ })
275
+
276
+ await manager.start(config, callback)
277
+ await manager.handleStart('task-1')
278
+
279
+ expect(receivedContext).toBeDefined()
280
+ expect(receivedContext!.userId).toBe('user-1')
281
+ expect(receivedContext!.extension.id).toBe('test-extension')
282
+ expect(receivedContext!.extension.version).toBe('1.0.0')
283
+ expect(receivedContext!.extension.storagePath).toBe('/fake/path')
284
+ expect(receivedContext!.signal).toBeDefined()
285
+ expect(receivedContext!.reportHealth).toBeDefined()
286
+ expect(receivedContext!.log).toBeDefined()
287
+ })
288
+ })
289
+
290
+ describe('handleStop', () => {
291
+ it('should stop the task', async () => {
292
+ const config: BackgroundTaskConfig = {
293
+ id: 'task-1',
294
+ name: 'Test Task',
295
+ userId: 'user-1',
296
+ restartPolicy: { type: 'never' },
297
+ }
298
+
299
+ await manager.start(config, vi.fn())
300
+ sendTaskStatus.mockClear()
301
+
302
+ manager.handleStop('task-1')
303
+
304
+ expect(sendTaskStatus).toHaveBeenCalledWith('task-1', 'stopped')
305
+ })
306
+ })
307
+
308
+ describe('reportHealth', () => {
309
+ it('should send health report to host', async () => {
310
+ const config: BackgroundTaskConfig = {
311
+ id: 'task-1',
312
+ name: 'Test Task',
313
+ userId: 'user-1',
314
+ restartPolicy: { type: 'never' },
315
+ }
316
+
317
+ let reportHealthFn: ((status: string) => void) | null = null
318
+
319
+ const callback = vi.fn(async (context) => {
320
+ reportHealthFn = context.reportHealth
321
+ })
322
+
323
+ await manager.start(config, callback)
324
+ await manager.handleStart('task-1')
325
+
326
+ expect(reportHealthFn).toBeDefined()
327
+
328
+ reportHealthFn!('Processing 100 items...')
329
+
330
+ expect(sendHealthReport).toHaveBeenCalledWith(
331
+ 'task-1',
332
+ 'Processing 100 items...',
333
+ expect.any(String)
334
+ )
335
+ })
336
+ })
337
+
338
+ describe('getStatus', () => {
339
+ it('should return status of all tasks', async () => {
340
+ const config1: BackgroundTaskConfig = {
341
+ id: 'task-1',
342
+ name: 'Test Task 1',
343
+ userId: 'user-1',
344
+ restartPolicy: { type: 'never' },
345
+ }
346
+ const config2: BackgroundTaskConfig = {
347
+ id: 'task-2',
348
+ name: 'Test Task 2',
349
+ userId: 'user-2',
350
+ restartPolicy: { type: 'always' },
351
+ }
352
+
353
+ await manager.start(config1, vi.fn())
354
+ await manager.start(config2, vi.fn())
355
+
356
+ const statuses = manager.getStatus()
357
+
358
+ expect(statuses).toHaveLength(2)
359
+ expect(statuses.find((s) => s.taskId === 'task-1')).toMatchObject({
360
+ taskId: 'task-1',
361
+ name: 'Test Task 1',
362
+ userId: 'user-1',
363
+ status: 'pending',
364
+ restartCount: 0,
365
+ })
366
+ expect(statuses.find((s) => s.taskId === 'task-2')).toMatchObject({
367
+ taskId: 'task-2',
368
+ name: 'Test Task 2',
369
+ userId: 'user-2',
370
+ status: 'pending',
371
+ restartCount: 0,
372
+ })
373
+ })
374
+ })
375
+
376
+ describe('dispose', () => {
377
+ it('should abort all running tasks', async () => {
378
+ const config1: BackgroundTaskConfig = {
379
+ id: 'task-1',
380
+ name: 'Test Task 1',
381
+ userId: 'user-1',
382
+ restartPolicy: { type: 'never' },
383
+ }
384
+ const config2: BackgroundTaskConfig = {
385
+ id: 'task-2',
386
+ name: 'Test Task 2',
387
+ userId: 'user-2',
388
+ restartPolicy: { type: 'never' },
389
+ }
390
+
391
+ let task1Aborted = false
392
+ let task2Aborted = false
393
+
394
+ const callback1 = vi.fn(async ({ signal }) => {
395
+ return new Promise<void>((resolve) => {
396
+ const timeout = setTimeout(resolve, 1000)
397
+ signal.addEventListener('abort', () => {
398
+ clearTimeout(timeout)
399
+ task1Aborted = true
400
+ resolve()
401
+ })
402
+ })
403
+ })
404
+
405
+ const callback2 = vi.fn(async ({ signal }) => {
406
+ return new Promise<void>((resolve) => {
407
+ const timeout = setTimeout(resolve, 1000)
408
+ signal.addEventListener('abort', () => {
409
+ clearTimeout(timeout)
410
+ task2Aborted = true
411
+ resolve()
412
+ })
413
+ })
414
+ })
415
+
416
+ await manager.start(config1, callback1)
417
+ await manager.start(config2, callback2)
418
+
419
+ const promise1 = manager.handleStart('task-1')
420
+ const promise2 = manager.handleStart('task-2')
421
+
422
+ await new Promise((resolve) => setTimeout(resolve, 10))
423
+
424
+ manager.dispose()
425
+
426
+ await Promise.all([promise1, promise2])
427
+
428
+ expect(task1Aborted).toBe(true)
429
+ expect(task2Aborted).toBe(true)
430
+ expect(manager.getStatus()).toHaveLength(0)
431
+ })
432
+ })
433
+
434
+ describe('race condition scenarios', () => {
435
+ it('should handle dispose while task is running', async () => {
436
+ const config: BackgroundTaskConfig = {
437
+ id: 'task-1',
438
+ name: 'Test Task',
439
+ userId: 'user-1',
440
+ restartPolicy: { type: 'never' },
441
+ }
442
+
443
+ let disposed = false
444
+
445
+ const callback = vi.fn(async ({ signal }) => {
446
+ return new Promise<void>((resolve) => {
447
+ const timeout = setTimeout(resolve, 100)
448
+ signal.addEventListener('abort', () => {
449
+ clearTimeout(timeout)
450
+ disposed = true
451
+ resolve()
452
+ })
453
+ })
454
+ })
455
+
456
+ const disposable = await manager.start(config, callback)
457
+ const handlePromise = manager.handleStart('task-1')
458
+
459
+ // Dispose while running
460
+ await new Promise((resolve) => setTimeout(resolve, 10))
461
+ disposable.dispose()
462
+
463
+ await handlePromise
464
+
465
+ expect(disposed).toBe(true)
466
+ })
467
+
468
+ it('should handle stop before task starts', async () => {
469
+ const config: BackgroundTaskConfig = {
470
+ id: 'task-1',
471
+ name: 'Test Task',
472
+ userId: 'user-1',
473
+ restartPolicy: { type: 'never' },
474
+ }
475
+
476
+ await manager.start(config, vi.fn())
477
+
478
+ // Stop before handleStart is called
479
+ manager.stop('task-1')
480
+ sendTaskStatus.mockClear()
481
+
482
+ // Now start it
483
+ await manager.handleStart('task-1')
484
+
485
+ // Should immediately detect it's aborted
486
+ expect(sendTaskStatus).toHaveBeenCalledWith('task-1', 'running')
487
+ expect(sendTaskStatus).toHaveBeenCalledWith('task-1', 'stopped')
488
+ })
489
+
490
+ it('should handle multiple rapid start/stop cycles', async () => {
491
+ const config: BackgroundTaskConfig = {
492
+ id: 'task-1',
493
+ name: 'Test Task',
494
+ userId: 'user-1',
495
+ restartPolicy: { type: 'never' },
496
+ }
497
+
498
+ const callback = vi.fn(async ({ signal }) => {
499
+ return new Promise<void>((resolve) => {
500
+ const timeout = setTimeout(resolve, 50)
501
+ signal.addEventListener('abort', () => {
502
+ clearTimeout(timeout)
503
+ resolve()
504
+ })
505
+ })
506
+ })
507
+
508
+ await manager.start(config, callback)
509
+
510
+ // Rapidly start and stop
511
+ const promises: Promise<void>[] = []
512
+ for (let i = 0; i < 5; i++) {
513
+ promises.push(manager.handleStart('task-1'))
514
+ await new Promise((resolve) => setTimeout(resolve, 5))
515
+ manager.stop('task-1')
516
+ await new Promise((resolve) => setTimeout(resolve, 5))
517
+ }
518
+
519
+ await Promise.all(promises)
520
+
521
+ // Should have handled all starts
522
+ expect(callback).toHaveBeenCalledTimes(5)
523
+ })
524
+ })
525
+ })