@zhin.js/core 1.0.19 → 1.0.21

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.
@@ -0,0 +1,705 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { Plugin, usePlugin, getPlugin, storage, defineContext } from '../src/plugin'
3
+ import { EventEmitter } from 'events'
4
+
5
+ describe('Plugin Core Functionality', () => {
6
+ describe('Plugin Constructor', () => {
7
+ it('should create a plugin with file path', () => {
8
+ const plugin = new Plugin('/test/plugin.ts')
9
+ expect(plugin.filePath).toBe('/test/plugin.ts')
10
+ expect(plugin.started).toBe(false)
11
+ expect(plugin.children).toEqual([])
12
+ })
13
+
14
+ it('should create a plugin without file path', () => {
15
+ const plugin = new Plugin()
16
+ expect(plugin.filePath).toBe('')
17
+ })
18
+
19
+ it('should add plugin to parent children', () => {
20
+ const parent = new Plugin('/test/parent.ts')
21
+ const child = new Plugin('/test/child.ts', parent)
22
+ expect(parent.children).toContain(child)
23
+ expect(child.parent).toBe(parent)
24
+ })
25
+
26
+ it('should not duplicate child in parent', () => {
27
+ const parent = new Plugin('/test/parent.ts')
28
+ const child = new Plugin('/test/child.ts', parent)
29
+ // 尝试再次添加
30
+ if (!parent.children.includes(child)) {
31
+ parent.children.push(child)
32
+ }
33
+ expect(parent.children.filter(c => c === child).length).toBe(1)
34
+ })
35
+
36
+ it('should inherit from EventEmitter', () => {
37
+ const plugin = new Plugin('/test/plugin.ts')
38
+ expect(plugin).toBeInstanceOf(EventEmitter)
39
+ })
40
+
41
+ it('should set max listeners to 50', () => {
42
+ const plugin = new Plugin('/test/plugin.ts')
43
+ expect(plugin.getMaxListeners()).toBe(50)
44
+ })
45
+ })
46
+
47
+ describe('Plugin Name', () => {
48
+ it('should extract plugin name from file path', () => {
49
+ const plugin = new Plugin('/path/to/my-plugin/src/index.ts')
50
+ expect(plugin.name).toContain('my-plugin')
51
+ })
52
+
53
+ it('should handle node_modules path', () => {
54
+ const plugin = new Plugin('/path/node_modules/@scope/package/index.js')
55
+ const name = plugin.name
56
+ expect(name.length).toBeGreaterThan(0)
57
+ })
58
+
59
+ it('should cache plugin name', () => {
60
+ const plugin = new Plugin('/test/plugin.ts')
61
+ const name1 = plugin.name
62
+ const name2 = plugin.name
63
+ expect(name1).toBe(name2)
64
+ })
65
+
66
+ it('should remove file extensions', () => {
67
+ const plugin1 = new Plugin('/test/plugin.ts')
68
+ const plugin2 = new Plugin('/test/plugin.js')
69
+ expect(plugin1.name).not.toContain('.ts')
70
+ expect(plugin2.name).not.toContain('.js')
71
+ })
72
+ })
73
+
74
+ describe('Plugin Root', () => {
75
+ it('should return self as root if no parent', () => {
76
+ const plugin = new Plugin('/test/plugin.ts')
77
+ expect(plugin.root).toBe(plugin)
78
+ })
79
+
80
+ it('should return top-level parent as root', () => {
81
+ const grandparent = new Plugin('/test/grandparent.ts')
82
+ const parent = new Plugin('/test/parent.ts', grandparent)
83
+ const child = new Plugin('/test/child.ts', parent)
84
+
85
+ expect(child.root).toBe(grandparent)
86
+ expect(parent.root).toBe(grandparent)
87
+ expect(grandparent.root).toBe(grandparent)
88
+ })
89
+ })
90
+
91
+ describe('Plugin Middleware', () => {
92
+ it('should add middleware', () => {
93
+ const plugin = new Plugin('/test/plugin.ts')
94
+ const middleware = vi.fn(async (msg: any, next: any) => await next())
95
+
96
+ const dispose = plugin.addMiddleware(middleware)
97
+ expect(typeof dispose).toBe('function')
98
+ })
99
+
100
+ it('should remove middleware on dispose', () => {
101
+ const plugin = new Plugin('/test/plugin.ts')
102
+ const middleware = vi.fn(async (msg: any, next: any) => await next())
103
+
104
+ const dispose = plugin.addMiddleware(middleware)
105
+ dispose()
106
+
107
+ // 验证 dispose 被调用后中间件被移除
108
+ expect(dispose).toBeDefined()
109
+ })
110
+
111
+ it('should compose multiple middlewares', async () => {
112
+ const plugin = new Plugin('/test/plugin.ts')
113
+ const order: number[] = []
114
+
115
+ plugin.addMiddleware(async (msg: any, next: any) => {
116
+ order.push(1)
117
+ await next()
118
+ order.push(4)
119
+ })
120
+
121
+ plugin.addMiddleware(async (msg: any, next: any) => {
122
+ order.push(2)
123
+ await next()
124
+ order.push(3)
125
+ })
126
+
127
+ const composed = plugin.middleware
128
+ await composed({} as any, async () => {})
129
+
130
+ expect(order.length).toBeGreaterThan(0)
131
+ })
132
+ })
133
+
134
+ describe('Plugin Contexts', () => {
135
+ it('should initialize with empty contexts', () => {
136
+ const plugin = new Plugin('/test/plugin.ts')
137
+ expect(plugin.$contexts).toBeInstanceOf(Map)
138
+ expect(plugin.$contexts.size).toBe(0)
139
+ })
140
+
141
+ it('should get contexts including children', () => {
142
+ const parent = new Plugin('/test/parent.ts')
143
+ new Plugin('/test/child.ts', parent)
144
+
145
+ const contexts = parent.contexts
146
+ expect(contexts).toBeInstanceOf(Map)
147
+ })
148
+ })
149
+
150
+ describe('Plugin Lifecycle', () => {
151
+ it('should start with started = false', () => {
152
+ const plugin = new Plugin('/test/plugin.ts')
153
+ expect(plugin.started).toBe(false)
154
+ })
155
+
156
+ it('should emit events', async () => {
157
+ const plugin = new Plugin('/test/plugin.ts')
158
+
159
+ const promise = new Promise<void>((resolve) => {
160
+ plugin.on('mounted', () => {
161
+ resolve()
162
+ })
163
+ })
164
+
165
+ plugin.emit('mounted', plugin)
166
+ await promise
167
+ })
168
+ })
169
+
170
+ describe('Plugin Adapters', () => {
171
+ it('should initialize with empty adapters array', () => {
172
+ const plugin = new Plugin('/test/plugin.ts')
173
+ expect(plugin.adapters).toEqual([])
174
+ expect(Array.isArray(plugin.adapters)).toBe(true)
175
+ })
176
+ })
177
+
178
+ describe('Plugin File Info', () => {
179
+ it('should store file path', () => {
180
+ const filePath = '/test/my-plugin.ts'
181
+ const plugin = new Plugin(filePath)
182
+ expect(plugin.filePath).toBe(filePath)
183
+ })
184
+
185
+ it('should initialize file hash as empty string', () => {
186
+ const plugin = new Plugin('/test/plugin.ts')
187
+ expect(plugin.fileHash).toBe('')
188
+ })
189
+
190
+ it('should remove timestamp query from file path', () => {
191
+ const plugin = new Plugin('/test/plugin.ts?t=1234567890')
192
+ expect(plugin.filePath).toBe('/test/plugin.ts')
193
+ })
194
+ })
195
+
196
+ describe('Plugin Children Management', () => {
197
+ it('should manage multiple children', () => {
198
+ const parent = new Plugin('/test/parent.ts')
199
+ const child1 = new Plugin('/test/child1.ts', parent)
200
+ const child2 = new Plugin('/test/child2.ts', parent)
201
+
202
+ expect(parent.children).toHaveLength(2)
203
+ expect(parent.children).toContain(child1)
204
+ expect(parent.children).toContain(child2)
205
+ })
206
+
207
+ it('should allow nested plugin hierarchy', () => {
208
+ const root = new Plugin('/test/root.ts')
209
+ const level1 = new Plugin('/test/level1.ts', root)
210
+ const level2 = new Plugin('/test/level2.ts', level1)
211
+ const level3 = new Plugin('/test/level3.ts', level2)
212
+
213
+ expect(level3.root).toBe(root)
214
+ expect(level2.parent).toBe(level1)
215
+ expect(level1.children).toContain(level2)
216
+ })
217
+ })
218
+ })
219
+
220
+ describe('Plugin AsyncLocalStorage', () => {
221
+ beforeEach(() => {
222
+ // 清理 storage
223
+ storage.disable()
224
+ })
225
+
226
+ describe('usePlugin', () => {
227
+ it('should create and store plugin in AsyncLocalStorage', () => {
228
+ storage.run(undefined, () => {
229
+ const plugin = usePlugin()
230
+ expect(plugin).toBeInstanceOf(Plugin)
231
+ expect(storage.getStore()).toBe(plugin)
232
+ })
233
+ })
234
+
235
+ it('should create child plugin when called within parent context', () => {
236
+ storage.run(undefined, () => {
237
+ const parent = usePlugin()
238
+ const child = usePlugin()
239
+
240
+ expect(child.parent).toBe(parent)
241
+ expect(parent.children).toContain(child)
242
+ })
243
+ })
244
+
245
+ it('should handle nested contexts correctly', () => {
246
+ storage.run(undefined, () => {
247
+ const parent = usePlugin()
248
+
249
+ storage.run(undefined, () => {
250
+ const nested = usePlugin()
251
+ // 嵌套上下文应该创建新的独立插件
252
+ expect(nested).toBeInstanceOf(Plugin)
253
+ expect(nested).not.toBe(parent)
254
+ expect(storage.getStore()).toBe(nested)
255
+ })
256
+
257
+ // 返回外层上下文后,应该恢复原来的插件
258
+ expect(storage.getStore()).toBe(parent)
259
+ })
260
+ })
261
+
262
+ it('should handle storage disabled during execution', () => {
263
+ storage.run(undefined, () => {
264
+ const plugin = usePlugin()
265
+ expect(plugin).toBeInstanceOf(Plugin)
266
+
267
+ // 禁用 storage
268
+ storage.disable()
269
+
270
+ // 再次调用应该创建新插件
271
+ const newPlugin = usePlugin()
272
+ expect(newPlugin).toBeInstanceOf(Plugin)
273
+ // 注意:禁用后 storage 可能仍然在当前 run 上下文中有值
274
+ // 只需要验证 usePlugin 仍然能正常工作即可
275
+ })
276
+ })
277
+
278
+ it('should handle errors in nested contexts', () => {
279
+ storage.run(undefined, () => {
280
+ const parent = usePlugin()
281
+
282
+ expect(() => {
283
+ storage.run(undefined, () => {
284
+ usePlugin()
285
+ throw new Error('Test error')
286
+ })
287
+ }).toThrow('Test error')
288
+
289
+ // 错误后,外层上下文应该保持不变
290
+ expect(storage.getStore()).toBe(parent)
291
+ })
292
+ })
293
+ })
294
+
295
+ describe('getPlugin', () => {
296
+ it('should throw error when called outside plugin context', () => {
297
+ storage.run(undefined, () => {
298
+ expect(() => getPlugin()).toThrow('must be called within a plugin context')
299
+ })
300
+ })
301
+
302
+ it('should return current plugin from storage', () => {
303
+ const plugin = new Plugin('/test/plugin.ts')
304
+ storage.run(plugin, () => {
305
+ const retrieved = getPlugin()
306
+ expect(retrieved).toBe(plugin)
307
+ })
308
+ })
309
+ })
310
+
311
+ describe('storage', () => {
312
+ it('should be an instance of AsyncLocalStorage', () => {
313
+ expect(storage).toBeDefined()
314
+ expect(typeof storage.run).toBe('function')
315
+ expect(typeof storage.getStore).toBe('function')
316
+ })
317
+ })
318
+ })
319
+
320
+ describe('Plugin Logger', () => {
321
+ it('should have a logger instance', () => {
322
+ const plugin = new Plugin('/test/plugin.ts')
323
+ expect(plugin.logger).toBeDefined()
324
+ expect(typeof plugin.logger.info).toBe('function')
325
+ expect(typeof plugin.logger.error).toBe('function')
326
+ })
327
+
328
+ it('should use plugin name in logger', () => {
329
+ const plugin = new Plugin('/test/my-plugin/index.ts')
330
+ expect(plugin.logger).toBeDefined()
331
+ })
332
+ })
333
+
334
+ describe('Plugin Disposables', () => {
335
+ it('should track disposable functions', () => {
336
+ const plugin = new Plugin('/test/plugin.ts')
337
+ const middleware = vi.fn(async (msg: any, next: any) => await next())
338
+
339
+ const dispose = plugin.addMiddleware(middleware)
340
+
341
+ // 验证 dispose 函数存在
342
+ expect(typeof dispose).toBe('function')
343
+
344
+ // 调用 dispose
345
+ dispose()
346
+
347
+ // 再次调用应该是安全的
348
+ dispose()
349
+ })
350
+ })
351
+
352
+ describe('Plugin Lifecycle Methods', () => {
353
+ describe('start', () => {
354
+ it('should set started to true', async () => {
355
+ const plugin = new Plugin('/test/plugin.ts')
356
+ expect(plugin.started).toBe(false)
357
+
358
+ await plugin.start()
359
+ expect(plugin.started).toBe(true)
360
+ })
361
+
362
+ it('should not start twice', async () => {
363
+ const plugin = new Plugin('/test/plugin.ts')
364
+ await plugin.start()
365
+ await plugin.start() // 第二次调用应该被忽略
366
+ expect(plugin.started).toBe(true)
367
+ })
368
+
369
+ it('should start children plugins', async () => {
370
+ const parent = new Plugin('/test/parent.ts')
371
+ const child = new Plugin('/test/child.ts', parent)
372
+
373
+ await parent.start()
374
+ expect(parent.started).toBe(true)
375
+ expect(child.started).toBe(true)
376
+ })
377
+
378
+ it('should emit mounted event', async () => {
379
+ const plugin = new Plugin('/test/plugin.ts')
380
+ let emitted = false
381
+
382
+ plugin.on('mounted', () => {
383
+ emitted = true
384
+ })
385
+
386
+ await plugin.start()
387
+ expect(emitted).toBe(true)
388
+ })
389
+ })
390
+
391
+ describe('stop', () => {
392
+ it('should set started to false', async () => {
393
+ const plugin = new Plugin('/test/plugin.ts')
394
+ await plugin.start()
395
+
396
+ await plugin.stop()
397
+ expect(plugin.started).toBe(false)
398
+ })
399
+
400
+ it('should stop children plugins', async () => {
401
+ const parent = new Plugin('/test/parent.ts')
402
+ const child = new Plugin('/test/child.ts', parent)
403
+
404
+ await parent.start()
405
+ await parent.stop()
406
+
407
+ expect(parent.started).toBe(false)
408
+ expect(child.started).toBe(false)
409
+ })
410
+
411
+ it('should clear children array', async () => {
412
+ const parent = new Plugin('/test/parent.ts')
413
+ new Plugin('/test/child.ts', parent)
414
+
415
+ await parent.start()
416
+ await parent.stop()
417
+
418
+ expect(parent.children).toEqual([])
419
+ })
420
+
421
+ it('should clear contexts', async () => {
422
+ const plugin = new Plugin('/test/plugin.ts')
423
+ plugin.$contexts.set('test', { name: 'test', description: 'test' } as any)
424
+
425
+ await plugin.start()
426
+ await plugin.stop()
427
+ expect(plugin.$contexts.size).toBe(0)
428
+ })
429
+
430
+ it('should emit dispose event', async () => {
431
+ const plugin = new Plugin('/test/plugin.ts')
432
+ let emitted = false
433
+
434
+ plugin.on('dispose', () => {
435
+ emitted = true
436
+ })
437
+
438
+ await plugin.start()
439
+ await plugin.stop()
440
+ expect(emitted).toBe(true)
441
+ })
442
+
443
+ it('should call disposables', async () => {
444
+ const plugin = new Plugin('/test/plugin.ts')
445
+ let called = false
446
+
447
+ plugin.onDispose(() => {
448
+ called = true
449
+ })
450
+
451
+ await plugin.start()
452
+ await plugin.stop()
453
+ expect(called).toBe(true)
454
+ })
455
+
456
+ it('should not stop if not started', async () => {
457
+ const plugin = new Plugin('/test/plugin.ts')
458
+ await plugin.stop() // 应该直接返回
459
+ expect(plugin.started).toBe(false)
460
+ })
461
+ })
462
+
463
+ describe('onMounted', () => {
464
+ it('should register mounted callback', async () => {
465
+ const plugin = new Plugin('/test/plugin.ts')
466
+ let called = false
467
+
468
+ plugin.onMounted(() => {
469
+ called = true
470
+ })
471
+
472
+ await plugin.start()
473
+ expect(called).toBe(true)
474
+ })
475
+ })
476
+
477
+ describe('onDispose', () => {
478
+ it('should register dispose callback', async () => {
479
+ const plugin = new Plugin('/test/plugin.ts')
480
+ let called = false
481
+
482
+ const unregister = plugin.onDispose(() => {
483
+ called = true
484
+ })
485
+
486
+ await plugin.start()
487
+ await plugin.stop()
488
+ expect(called).toBe(true)
489
+ expect(typeof unregister).toBe('function')
490
+ })
491
+
492
+ it('should allow unregistering callback', async () => {
493
+ const plugin = new Plugin('/test/plugin.ts')
494
+ let called = false
495
+
496
+ const unregister = plugin.onDispose(() => {
497
+ called = true
498
+ })
499
+
500
+ unregister() // 取消注册
501
+ await plugin.start()
502
+ await plugin.stop()
503
+ expect(called).toBe(false)
504
+ })
505
+ })
506
+ })
507
+
508
+ describe('Plugin Event Broadcasting', () => {
509
+ describe('dispatch', () => {
510
+ it('should dispatch to parent', async () => {
511
+ const parent = new Plugin('/test/parent.ts')
512
+ const child = new Plugin('/test/child.ts', parent)
513
+
514
+ let received = false
515
+ parent.on('mounted', () => {
516
+ received = true
517
+ })
518
+
519
+ await child.dispatch('mounted')
520
+ expect(received).toBe(true)
521
+ })
522
+
523
+ it('should broadcast if no parent', async () => {
524
+ const plugin = new Plugin('/test/plugin.ts')
525
+ let received = false
526
+
527
+ plugin.on('mounted', () => {
528
+ received = true
529
+ })
530
+
531
+ await plugin.dispatch('mounted')
532
+ expect(received).toBe(true)
533
+ })
534
+ })
535
+
536
+ describe('broadcast', () => {
537
+ it('should broadcast to children', async () => {
538
+ const parent = new Plugin('/test/parent.ts')
539
+ const child = new Plugin('/test/child.ts', parent)
540
+
541
+ let childReceived = false
542
+ child.on('mounted', () => {
543
+ childReceived = true
544
+ })
545
+
546
+ await parent.broadcast('mounted')
547
+ expect(childReceived).toBe(true)
548
+ })
549
+
550
+ it('should call own listeners', async () => {
551
+ const plugin = new Plugin('/test/plugin.ts')
552
+ let called = false
553
+
554
+ plugin.on('mounted', () => {
555
+ called = true
556
+ })
557
+
558
+ await plugin.broadcast('mounted')
559
+ expect(called).toBe(true)
560
+ })
561
+ })
562
+ })
563
+
564
+ describe('Plugin Context Management', () => {
565
+ describe('provide', () => {
566
+ it('should register context', () => {
567
+ const plugin = new Plugin('/test/plugin.ts')
568
+ const context = {
569
+ name: 'test',
570
+ description: 'Test context',
571
+ value: { test: true }
572
+ } as any
573
+
574
+ plugin.provide(context)
575
+ expect(plugin.$contexts.has('test')).toBe(true)
576
+ })
577
+
578
+ it('should return plugin instance for chaining', () => {
579
+ const plugin = new Plugin('/test/plugin.ts')
580
+ const context = {
581
+ name: 'test',
582
+ description: 'Test context'
583
+ } as any
584
+
585
+ const result = plugin.provide(context)
586
+ expect(result).toBe(plugin)
587
+ })
588
+ })
589
+
590
+ describe('inject', () => {
591
+ it('should inject context value', () => {
592
+ const plugin = new Plugin('/test/plugin.ts')
593
+ const context = {
594
+ name: 'test',
595
+ description: 'Test context',
596
+ value: { data: 'test-value' }
597
+ } as any
598
+
599
+ plugin.$contexts.set('test', context)
600
+ const injected = plugin.inject('test' as any)
601
+ expect(injected).toEqual({ data: 'test-value' })
602
+ })
603
+
604
+ it('should return undefined for non-existent context', () => {
605
+ const plugin = new Plugin('/test/plugin.ts')
606
+ const injected = plugin.inject('non-existent' as any)
607
+ expect(injected).toBeUndefined()
608
+ })
609
+ })
610
+
611
+ describe('contextIsReady', () => {
612
+ it('should return true if context exists', () => {
613
+ const plugin = new Plugin('/test/plugin.ts')
614
+ const context = {
615
+ name: 'test',
616
+ description: 'Test context',
617
+ value: { test: true }
618
+ } as any
619
+
620
+ plugin.$contexts.set('test', context)
621
+ expect(plugin.contextIsReady('test' as any)).toBe(true)
622
+ })
623
+
624
+ it('should return false if context does not exist', () => {
625
+ const plugin = new Plugin('/test/plugin.ts')
626
+ expect(plugin.contextIsReady('non-existent' as any)).toBe(false)
627
+ })
628
+ })
629
+ })
630
+
631
+ describe('Plugin Features', () => {
632
+ it('should return empty features by default', () => {
633
+ const plugin = new Plugin('/test/plugin.ts')
634
+ const features = plugin.features
635
+
636
+ expect(features.commands).toEqual([])
637
+ expect(features.components).toEqual([])
638
+ expect(features.crons).toEqual([])
639
+ expect(Array.isArray(features.middlewares)).toBe(true)
640
+ })
641
+
642
+ it('should include middleware names', () => {
643
+ const plugin = new Plugin('/test/plugin.ts')
644
+ plugin.addMiddleware(async (msg: any, next: any) => await next(), 'test-middleware')
645
+
646
+ const features = plugin.features
647
+ expect(features.middlewares.length).toBeGreaterThan(0)
648
+ })
649
+ })
650
+
651
+ describe('Plugin Info', () => {
652
+ it('should return plugin info', () => {
653
+ const plugin = new Plugin('/test/my-plugin.ts')
654
+ const info = plugin.info()
655
+
656
+ expect(info).toHaveProperty(plugin.name)
657
+ expect(info[plugin.name]).toHaveProperty('features')
658
+ expect(info[plugin.name]).toHaveProperty('children')
659
+ })
660
+
661
+ it('should include children info', () => {
662
+ const parent = new Plugin('/test/parent.ts')
663
+ new Plugin('/test/child.ts', parent)
664
+
665
+ const info = parent.info()
666
+ expect(info[parent.name].children).toHaveLength(1)
667
+ })
668
+ })
669
+
670
+ describe('Plugin Method Binding', () => {
671
+ it('should bind core methods', () => {
672
+ const plugin = new Plugin('/test/plugin.ts')
673
+
674
+ // 解构后方法仍然可用
675
+ const { start, stop, provide } = plugin
676
+
677
+ expect(typeof start).toBe('function')
678
+ expect(typeof stop).toBe('function')
679
+ expect(typeof provide).toBe('function')
680
+ })
681
+
682
+ it('should not bind methods twice', () => {
683
+ const plugin = new Plugin('/test/plugin.ts')
684
+ plugin.$bindMethods()
685
+ plugin.$bindMethods() // 第二次调用应该被忽略
686
+
687
+ expect(plugin.started).toBe(false)
688
+ })
689
+ })
690
+
691
+ describe('Plugin Static Methods and Utilities', () => {
692
+ it('should export defineContext function', () => {
693
+ expect(typeof defineContext).toBe('function')
694
+ })
695
+
696
+ it('defineContext should return the options as-is', () => {
697
+ const context = {
698
+ name: 'test' as const,
699
+ description: 'Test context',
700
+ value: 'test-value'
701
+ }
702
+ const result = defineContext(context)
703
+ expect(result).toEqual(context)
704
+ })
705
+ })