@navios/di 0.5.0 → 0.6.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.
Files changed (123) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +196 -219
  3. package/docs/README.md +69 -11
  4. package/docs/api-reference.md +281 -117
  5. package/docs/container.md +220 -56
  6. package/docs/examples/request-scope-example.mts +2 -2
  7. package/docs/factory.md +3 -8
  8. package/docs/getting-started.md +37 -8
  9. package/docs/migration.md +318 -37
  10. package/docs/request-contexts.md +263 -175
  11. package/docs/scopes.md +79 -42
  12. package/lib/browser/index.d.mts +1577 -0
  13. package/lib/browser/index.d.mts.map +1 -0
  14. package/lib/browser/index.mjs +3012 -0
  15. package/lib/browser/index.mjs.map +1 -0
  16. package/lib/index-S_qX2VLI.d.mts +1211 -0
  17. package/lib/index-S_qX2VLI.d.mts.map +1 -0
  18. package/lib/index-fKPuT65j.d.cts +1206 -0
  19. package/lib/index-fKPuT65j.d.cts.map +1 -0
  20. package/lib/index.cjs +389 -0
  21. package/lib/index.cjs.map +1 -0
  22. package/lib/index.d.cts +376 -0
  23. package/lib/index.d.cts.map +1 -0
  24. package/lib/index.d.mts +371 -78
  25. package/lib/index.d.mts.map +1 -0
  26. package/lib/index.mjs +325 -63
  27. package/lib/index.mjs.map +1 -1
  28. package/lib/testing/index.cjs +9 -0
  29. package/lib/testing/index.d.cts +2 -0
  30. package/lib/testing/index.d.mts +2 -2
  31. package/lib/testing/index.mjs +2 -72
  32. package/lib/testing-BMGmmxH7.cjs +2895 -0
  33. package/lib/testing-BMGmmxH7.cjs.map +1 -0
  34. package/lib/testing-DCXz8AJD.mjs +2655 -0
  35. package/lib/testing-DCXz8AJD.mjs.map +1 -0
  36. package/package.json +26 -4
  37. package/project.json +2 -2
  38. package/src/__tests__/async-local-storage.browser.spec.mts +240 -0
  39. package/src/__tests__/async-local-storage.spec.mts +333 -0
  40. package/src/__tests__/container.spec.mts +30 -25
  41. package/src/__tests__/e2e.browser.spec.mts +790 -0
  42. package/src/__tests__/e2e.spec.mts +1222 -0
  43. package/src/__tests__/errors.spec.mts +6 -6
  44. package/src/__tests__/factory.spec.mts +1 -1
  45. package/src/__tests__/get-injectors.spec.mts +1 -1
  46. package/src/__tests__/injectable.spec.mts +1 -1
  47. package/src/__tests__/injection-token.spec.mts +1 -1
  48. package/src/__tests__/library-findings.spec.mts +563 -0
  49. package/src/__tests__/registry.spec.mts +2 -2
  50. package/src/__tests__/request-scope.spec.mts +266 -274
  51. package/src/__tests__/service-instantiator.spec.mts +19 -17
  52. package/src/__tests__/service-locator-event-bus.spec.mts +9 -9
  53. package/src/__tests__/service-locator-manager.spec.mts +15 -15
  54. package/src/__tests__/service-locator.spec.mts +167 -244
  55. package/src/__tests__/unified-api.spec.mts +27 -27
  56. package/src/__type-tests__/factory.spec-d.mts +2 -2
  57. package/src/__type-tests__/inject.spec-d.mts +2 -2
  58. package/src/__type-tests__/injectable.spec-d.mts +1 -1
  59. package/src/browser.mts +16 -0
  60. package/src/container/container.mts +319 -0
  61. package/src/container/index.mts +2 -0
  62. package/src/container/scoped-container.mts +350 -0
  63. package/src/decorators/factory.decorator.mts +4 -4
  64. package/src/decorators/injectable.decorator.mts +5 -5
  65. package/src/errors/di-error.mts +13 -7
  66. package/src/errors/index.mts +0 -8
  67. package/src/index.mts +156 -15
  68. package/src/interfaces/container.interface.mts +82 -0
  69. package/src/interfaces/factory.interface.mts +2 -2
  70. package/src/interfaces/index.mts +1 -0
  71. package/src/internal/context/async-local-storage.mts +120 -0
  72. package/src/internal/context/factory-context.mts +18 -0
  73. package/src/internal/context/index.mts +3 -0
  74. package/src/{request-context-holder.mts → internal/context/request-context.mts} +40 -27
  75. package/src/internal/context/resolution-context.mts +63 -0
  76. package/src/internal/context/sync-local-storage.mts +51 -0
  77. package/src/internal/core/index.mts +5 -0
  78. package/src/internal/core/instance-resolver.mts +641 -0
  79. package/src/{service-instantiator.mts → internal/core/instantiator.mts} +31 -27
  80. package/src/internal/core/invalidator.mts +437 -0
  81. package/src/internal/core/service-locator.mts +202 -0
  82. package/src/{token-processor.mts → internal/core/token-processor.mts} +79 -60
  83. package/src/{base-instance-holder-manager.mts → internal/holder/base-holder-manager.mts} +91 -21
  84. package/src/internal/holder/holder-manager.mts +85 -0
  85. package/src/internal/holder/holder-storage.interface.mts +116 -0
  86. package/src/internal/holder/index.mts +6 -0
  87. package/src/internal/holder/instance-holder.mts +109 -0
  88. package/src/internal/holder/request-storage.mts +134 -0
  89. package/src/internal/holder/singleton-storage.mts +105 -0
  90. package/src/internal/index.mts +4 -0
  91. package/src/internal/lifecycle/circular-detector.mts +77 -0
  92. package/src/internal/lifecycle/index.mts +2 -0
  93. package/src/{service-locator-event-bus.mts → internal/lifecycle/lifecycle-event-bus.mts} +12 -5
  94. package/src/testing/__tests__/test-container.spec.mts +2 -2
  95. package/src/testing/test-container.mts +4 -4
  96. package/src/token/index.mts +2 -0
  97. package/src/{injection-token.mts → token/injection-token.mts} +1 -1
  98. package/src/{registry.mts → token/registry.mts} +1 -1
  99. package/src/utils/get-injectable-token.mts +1 -1
  100. package/src/utils/get-injectors.mts +32 -15
  101. package/src/utils/types.mts +1 -1
  102. package/tsdown.config.mts +67 -0
  103. package/lib/_tsup-dts-rollup.d.mts +0 -1283
  104. package/lib/_tsup-dts-rollup.d.ts +0 -1283
  105. package/lib/chunk-44F3LXW5.mjs +0 -2043
  106. package/lib/chunk-44F3LXW5.mjs.map +0 -1
  107. package/lib/index.d.ts +0 -78
  108. package/lib/index.js +0 -2127
  109. package/lib/index.js.map +0 -1
  110. package/lib/testing/index.d.ts +0 -2
  111. package/lib/testing/index.js +0 -2060
  112. package/lib/testing/index.js.map +0 -1
  113. package/lib/testing/index.mjs.map +0 -1
  114. package/src/container.mts +0 -227
  115. package/src/factory-context.mts +0 -8
  116. package/src/instance-resolver.mts +0 -559
  117. package/src/request-context-manager.mts +0 -149
  118. package/src/service-invalidator.mts +0 -429
  119. package/src/service-locator-instance-holder.mts +0 -70
  120. package/src/service-locator-manager.mts +0 -85
  121. package/src/service-locator.mts +0 -246
  122. package/tsup.config.mts +0 -12
  123. /package/src/{injector.mts → injectors.mts} +0 -0
@@ -0,0 +1,790 @@
1
+ /**
2
+ * E2E tests for @navios/di running with SyncLocalStorage (browser mode).
3
+ *
4
+ * This file runs all the same E2E tests but with the sync polyfill forced,
5
+ * simulating browser environment behavior.
6
+ *
7
+ * The key difference: In browser mode, AsyncLocalStorage context does NOT
8
+ * propagate across async boundaries. This affects circular dependency detection
9
+ * for async operations, but synchronous DI operations should work identically.
10
+ */
11
+
12
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
13
+
14
+ import { Container } from '../container/container.mjs'
15
+ import { Injectable } from '../decorators/injectable.decorator.mjs'
16
+ import { InjectableScope } from '../enums/index.mjs'
17
+ import type { OnServiceDestroy } from '../interfaces/on-service-destroy.interface.mjs'
18
+ import type { OnServiceInit } from '../interfaces/on-service-init.interface.mjs'
19
+ import { Registry } from '../token/registry.mjs'
20
+ import { getInjectors } from '../utils/get-injectors.mjs'
21
+ import { __testing__, isUsingNativeAsyncLocalStorage } from '../internal/context/async-local-storage.mjs'
22
+
23
+ // Force sync mode for all tests in this file
24
+ beforeAll(() => {
25
+ __testing__.forceSyncMode()
26
+ })
27
+
28
+ afterAll(() => {
29
+ __testing__.reset()
30
+ })
31
+
32
+ // ============================================================================
33
+ // TEST UTILITIES
34
+ // ============================================================================
35
+
36
+ function delay(ms: number): Promise<void> {
37
+ return new Promise((resolve) => setTimeout(resolve, ms))
38
+ }
39
+
40
+ function createTestSetup() {
41
+ const registry = new Registry()
42
+ const injectors = getInjectors()
43
+ const container = new Container(registry, null, injectors)
44
+
45
+ return { registry, injectors, container }
46
+ }
47
+
48
+ // ============================================================================
49
+ // VERIFY BROWSER MODE
50
+ // ============================================================================
51
+
52
+ describe('Browser Mode Verification', () => {
53
+ it('should be using sync polyfill', () => {
54
+ expect(isUsingNativeAsyncLocalStorage()).toBe(false)
55
+ })
56
+ })
57
+
58
+ // ============================================================================
59
+ // SECTION 1: BASIC SETUP TESTS (Browser Mode)
60
+ // ============================================================================
61
+
62
+ describe('E2E Browser: Basic Setup', () => {
63
+ let registry: Registry
64
+ let container: Container
65
+ let injectors: ReturnType<typeof getInjectors>
66
+
67
+ beforeEach(() => {
68
+ const setup = createTestSetup()
69
+ registry = setup.registry
70
+ container = setup.container
71
+ injectors = setup.injectors
72
+ })
73
+
74
+ afterEach(async () => {
75
+ await container.dispose()
76
+ })
77
+
78
+ describe('Simple service registration and resolution', () => {
79
+ it('should register and resolve a simple singleton service', async () => {
80
+ @Injectable({ scope: InjectableScope.Singleton, registry })
81
+ class SimpleService {
82
+ getValue() {
83
+ return 'hello'
84
+ }
85
+ }
86
+
87
+ const instance = await container.get(SimpleService)
88
+ expect(instance).toBeInstanceOf(SimpleService)
89
+ expect(instance.getValue()).toBe('hello')
90
+ })
91
+
92
+ it('should return the same instance for singleton services', async () => {
93
+ @Injectable({ scope: InjectableScope.Singleton, registry })
94
+ class SingletonService {
95
+ id = Math.random()
96
+ }
97
+
98
+ const instance1 = await container.get(SingletonService)
99
+ const instance2 = await container.get(SingletonService)
100
+
101
+ expect(instance1).toBe(instance2)
102
+ expect(instance1.id).toBe(instance2.id)
103
+ })
104
+
105
+ it('should return different instances for transient services', async () => {
106
+ @Injectable({ scope: InjectableScope.Transient, registry })
107
+ class TransientService {
108
+ id = Math.random()
109
+ }
110
+
111
+ const instance1 = await container.get(TransientService)
112
+ const instance2 = await container.get(TransientService)
113
+
114
+ expect(instance1).not.toBe(instance2)
115
+ expect(instance1.id).not.toBe(instance2.id)
116
+ })
117
+ })
118
+
119
+ describe('Service with dependencies', () => {
120
+ it('should resolve a service that depends on another singleton', async () => {
121
+ @Injectable({ scope: InjectableScope.Singleton, registry })
122
+ class DatabaseService {
123
+ connect() {
124
+ return 'connected'
125
+ }
126
+ }
127
+
128
+ @Injectable({ scope: InjectableScope.Singleton, registry })
129
+ class UserRepository {
130
+ private db = injectors.inject(DatabaseService)
131
+
132
+ async getDatabase() {
133
+ return this.db
134
+ }
135
+ }
136
+
137
+ const repo = await container.get(UserRepository)
138
+ const db = await repo.getDatabase()
139
+ expect(db).toBeInstanceOf(DatabaseService)
140
+ expect(db.connect()).toBe('connected')
141
+ })
142
+
143
+ it('should resolve deep dependency chains', async () => {
144
+ @Injectable({ scope: InjectableScope.Singleton, registry })
145
+ class ServiceLevel1 {
146
+ name = 'Level1'
147
+ }
148
+
149
+ @Injectable({ scope: InjectableScope.Singleton, registry })
150
+ class ServiceLevel2 {
151
+ private level1 = injectors.inject(ServiceLevel1)
152
+ name = 'Level2'
153
+
154
+ async getLevel1() {
155
+ return this.level1
156
+ }
157
+ }
158
+
159
+ @Injectable({ scope: InjectableScope.Singleton, registry })
160
+ class ServiceLevel3 {
161
+ private level2 = injectors.inject(ServiceLevel2)
162
+ name = 'Level3'
163
+
164
+ async getLevel2() {
165
+ return this.level2
166
+ }
167
+ }
168
+
169
+ const level3 = await container.get(ServiceLevel3)
170
+ expect(level3.name).toBe('Level3')
171
+
172
+ const level2 = await level3.getLevel2()
173
+ expect(level2.name).toBe('Level2')
174
+
175
+ const level1 = await level2.getLevel1()
176
+ expect(level1.name).toBe('Level1')
177
+ })
178
+ })
179
+ })
180
+
181
+ // ============================================================================
182
+ // SECTION 2: MIXED SCOPES TESTS (Browser Mode)
183
+ // ============================================================================
184
+
185
+ describe('E2E Browser: Mixed Scopes', () => {
186
+ let registry: Registry
187
+ let container: Container
188
+ let injectors: ReturnType<typeof getInjectors>
189
+
190
+ beforeEach(() => {
191
+ const setup = createTestSetup()
192
+ registry = setup.registry
193
+ container = setup.container
194
+ injectors = setup.injectors
195
+ })
196
+
197
+ afterEach(async () => {
198
+ await container.dispose()
199
+ })
200
+
201
+ describe('Singleton with Request-scoped dependencies', () => {
202
+ it('should create different request-scoped instances for different requests', async () => {
203
+ let instanceCount = 0
204
+
205
+ @Injectable({ scope: InjectableScope.Request, registry })
206
+ class RequestService {
207
+ id = ++instanceCount
208
+ }
209
+
210
+ // First request
211
+ const scoped1 = container.beginRequest('request-1')
212
+ const service1 = await scoped1.get(RequestService)
213
+
214
+ // Second request
215
+ const scoped2 = container.beginRequest('request-2')
216
+ const service2 = await scoped2.get(RequestService)
217
+
218
+ expect(service1.id).toBe(1)
219
+ expect(service2.id).toBe(2)
220
+ expect(service1).not.toBe(service2)
221
+
222
+ await scoped1.endRequest()
223
+ await scoped2.endRequest()
224
+ })
225
+ })
226
+
227
+ describe('Transient within different scopes', () => {
228
+ it('should create new transient instance every time regardless of scope', async () => {
229
+ let instanceCount = 0
230
+
231
+ @Injectable({ scope: InjectableScope.Transient, registry })
232
+ class TransientService {
233
+ id = ++instanceCount
234
+ }
235
+
236
+ // From main container
237
+ const t1 = await container.get(TransientService)
238
+ const t2 = await container.get(TransientService)
239
+
240
+ // From scoped container
241
+ const scoped = container.beginRequest('request-1')
242
+ const t3 = await scoped.get(TransientService)
243
+ const t4 = await scoped.get(TransientService)
244
+
245
+ expect(t1.id).toBe(1)
246
+ expect(t2.id).toBe(2)
247
+ expect(t3.id).toBe(3)
248
+ expect(t4.id).toBe(4)
249
+
250
+ await scoped.endRequest()
251
+ })
252
+ })
253
+
254
+ describe('Complex dependency graphs', () => {
255
+ it('should handle complex dependency graph with all scope types', async () => {
256
+ const creationOrder: string[] = []
257
+
258
+ @Injectable({ scope: InjectableScope.Singleton, registry })
259
+ class DatabaseConnection {
260
+ constructor() {
261
+ creationOrder.push('DatabaseConnection')
262
+ }
263
+ query() {
264
+ return 'query result'
265
+ }
266
+ }
267
+
268
+ @Injectable({ scope: InjectableScope.Request, registry })
269
+ class RequestLogger {
270
+ private db = injectors.inject(DatabaseConnection)
271
+
272
+ constructor() {
273
+ creationOrder.push('RequestLogger')
274
+ }
275
+
276
+ async log(msg: string) {
277
+ const db = await this.db
278
+ return `${msg} - ${db.query()}`
279
+ }
280
+ }
281
+
282
+ @Injectable({ scope: InjectableScope.Transient, registry })
283
+ class ActionHandler {
284
+ private logger = injectors.inject(RequestLogger)
285
+
286
+ constructor() {
287
+ creationOrder.push('ActionHandler')
288
+ }
289
+
290
+ async handle() {
291
+ const log = await this.logger
292
+ return log.log('action')
293
+ }
294
+ }
295
+
296
+ @Injectable({ scope: InjectableScope.Request, registry })
297
+ class RequestProcessor {
298
+ private action = injectors.inject(ActionHandler)
299
+
300
+ constructor() {
301
+ creationOrder.push('RequestProcessor')
302
+ }
303
+
304
+ async process() {
305
+ const handler = await this.action
306
+ return handler.handle()
307
+ }
308
+ }
309
+
310
+ const scoped = container.beginRequest('request-1')
311
+
312
+ const processor = await scoped.get(RequestProcessor)
313
+ const result = await processor.process()
314
+
315
+ expect(result).toBe('action - query result')
316
+ expect(creationOrder).toContain('DatabaseConnection')
317
+ expect(creationOrder).toContain('RequestLogger')
318
+ expect(creationOrder).toContain('ActionHandler')
319
+ expect(creationOrder).toContain('RequestProcessor')
320
+
321
+ await scoped.endRequest()
322
+ })
323
+ })
324
+ })
325
+
326
+ // ============================================================================
327
+ // SECTION 3: CONCURRENT REQUEST TESTS (Browser Mode)
328
+ // ============================================================================
329
+
330
+ describe('E2E Browser: Concurrent Requests', () => {
331
+ let registry: Registry
332
+ let container: Container
333
+ let injectors: ReturnType<typeof getInjectors>
334
+
335
+ beforeEach(() => {
336
+ const setup = createTestSetup()
337
+ registry = setup.registry
338
+ container = setup.container
339
+ injectors = setup.injectors
340
+ })
341
+
342
+ afterEach(async () => {
343
+ await container.dispose()
344
+ })
345
+
346
+ describe('Parallel request processing', () => {
347
+ it('should share singleton across all concurrent requests', async () => {
348
+ let singletonCreationCount = 0
349
+
350
+ @Injectable({ scope: InjectableScope.Singleton, registry })
351
+ class SharedCache {
352
+ id = ++singletonCreationCount
353
+ private data = new Map<string, string>()
354
+
355
+ set(key: string, value: string) {
356
+ this.data.set(key, value)
357
+ }
358
+
359
+ get(key: string) {
360
+ return this.data.get(key)
361
+ }
362
+ }
363
+
364
+ @Injectable({ scope: InjectableScope.Request, registry })
365
+ class RequestService {
366
+ private cache = injectors.inject(SharedCache)
367
+
368
+ async setInCache(key: string, value: string) {
369
+ const c = await this.cache
370
+ c.set(key, value)
371
+ return c.id
372
+ }
373
+
374
+ async getFromCache(key: string) {
375
+ const c = await this.cache
376
+ return c.get(key)
377
+ }
378
+ }
379
+
380
+ // Start multiple requests that all use the cache
381
+ const requests = Array.from({ length: 10 }, async (_, i) => {
382
+ const scoped = container.beginRequest(`request-${i}`)
383
+ const service = await scoped.get(RequestService)
384
+ const cacheId = await service.setInCache(`key-${i}`, `value-${i}`)
385
+ await scoped.endRequest()
386
+ return cacheId
387
+ })
388
+
389
+ const cacheIds = await Promise.all(requests)
390
+
391
+ // All requests should have used the same singleton
392
+ expect(singletonCreationCount).toBe(1)
393
+ expect(new Set(cacheIds).size).toBe(1)
394
+ })
395
+
396
+ it('should handle race conditions when creating the same singleton', async () => {
397
+ let creationCount = 0
398
+ let constructorCallCount = 0
399
+
400
+ @Injectable({ scope: InjectableScope.Singleton, registry })
401
+ class SlowSingleton {
402
+ id: number
403
+
404
+ constructor() {
405
+ constructorCallCount++
406
+ this.id = ++creationCount
407
+ }
408
+ }
409
+
410
+ // Try to get the same singleton from many places concurrently
411
+ const results = await Promise.all([
412
+ container.get(SlowSingleton),
413
+ container.get(SlowSingleton),
414
+ container.get(SlowSingleton),
415
+ container.get(SlowSingleton),
416
+ container.get(SlowSingleton),
417
+ ])
418
+
419
+ // All should be the same instance
420
+ const uniqueInstances = new Set(results)
421
+ expect(uniqueInstances.size).toBe(1)
422
+ expect(creationCount).toBe(1)
423
+ expect(constructorCallCount).toBe(1)
424
+ })
425
+ })
426
+ })
427
+
428
+ // ============================================================================
429
+ // SECTION 4: SERVICE LIFECYCLE TESTS (Browser Mode)
430
+ // ============================================================================
431
+
432
+ describe('E2E Browser: Service Lifecycle', () => {
433
+ let registry: Registry
434
+ let container: Container
435
+ let injectors: ReturnType<typeof getInjectors>
436
+
437
+ beforeEach(() => {
438
+ const setup = createTestSetup()
439
+ registry = setup.registry
440
+ container = setup.container
441
+ injectors = setup.injectors
442
+ })
443
+
444
+ afterEach(async () => {
445
+ await container.dispose()
446
+ })
447
+
448
+ describe('OnServiceInit lifecycle', () => {
449
+ it('should call onServiceInit when service is created', async () => {
450
+ const initSpy = vi.fn()
451
+
452
+ @Injectable({ scope: InjectableScope.Singleton, registry })
453
+ class ServiceWithInit implements OnServiceInit {
454
+ initialized = false
455
+
456
+ async onServiceInit() {
457
+ initSpy()
458
+ this.initialized = true
459
+ }
460
+ }
461
+
462
+ const service = await container.get(ServiceWithInit)
463
+
464
+ expect(initSpy).toHaveBeenCalledTimes(1)
465
+ expect(service.initialized).toBe(true)
466
+ })
467
+
468
+ it('should call onServiceInit for request-scoped services in each request', async () => {
469
+ let initCount = 0
470
+
471
+ @Injectable({ scope: InjectableScope.Request, registry })
472
+ class RequestServiceWithInit implements OnServiceInit {
473
+ initOrder = 0
474
+
475
+ onServiceInit() {
476
+ this.initOrder = ++initCount
477
+ }
478
+ }
479
+
480
+ const scoped1 = container.beginRequest('request-1')
481
+ const service1 = await scoped1.get(RequestServiceWithInit)
482
+
483
+ const scoped2 = container.beginRequest('request-2')
484
+ const service2 = await scoped2.get(RequestServiceWithInit)
485
+
486
+ expect(service1.initOrder).toBe(1)
487
+ expect(service2.initOrder).toBe(2)
488
+ expect(initCount).toBe(2)
489
+
490
+ await scoped1.endRequest()
491
+ await scoped2.endRequest()
492
+ })
493
+ })
494
+
495
+ describe('OnServiceDestroy lifecycle', () => {
496
+ it('should call onServiceDestroy when container is disposed', async () => {
497
+ const destroySpy = vi.fn()
498
+
499
+ @Injectable({ scope: InjectableScope.Singleton, registry })
500
+ class ServiceWithDestroy implements OnServiceDestroy {
501
+ onServiceDestroy() {
502
+ destroySpy()
503
+ }
504
+ }
505
+
506
+ await container.get(ServiceWithDestroy)
507
+ expect(destroySpy).not.toHaveBeenCalled()
508
+
509
+ await container.dispose()
510
+ expect(destroySpy).toHaveBeenCalledTimes(1)
511
+ })
512
+
513
+ it('should call onServiceDestroy for request-scoped services when request ends', async () => {
514
+ const destroySpy = vi.fn()
515
+
516
+ @Injectable({ scope: InjectableScope.Request, registry })
517
+ class RequestServiceWithDestroy implements OnServiceDestroy {
518
+ onServiceDestroy() {
519
+ destroySpy()
520
+ }
521
+ }
522
+
523
+ const scoped = container.beginRequest('request-1')
524
+ await scoped.get(RequestServiceWithDestroy)
525
+
526
+ expect(destroySpy).not.toHaveBeenCalled()
527
+
528
+ await scoped.endRequest()
529
+ expect(destroySpy).toHaveBeenCalledTimes(1)
530
+ })
531
+
532
+ it('should handle async onServiceDestroy', async () => {
533
+ const events: string[] = []
534
+
535
+ @Injectable({ scope: InjectableScope.Singleton, registry })
536
+ class SlowDestroyService implements OnServiceDestroy {
537
+ async onServiceDestroy() {
538
+ events.push('destroy-start')
539
+ await delay(10)
540
+ events.push('destroy-end')
541
+ }
542
+ }
543
+
544
+ await container.get(SlowDestroyService)
545
+ await container.dispose()
546
+
547
+ expect(events).toEqual(['destroy-start', 'destroy-end'])
548
+ })
549
+ })
550
+
551
+ describe('Combined lifecycle methods', () => {
552
+ it('should call lifecycle methods in correct order', async () => {
553
+ const events: string[] = []
554
+
555
+ @Injectable({ scope: InjectableScope.Request, registry })
556
+ class FullLifecycleService implements OnServiceInit, OnServiceDestroy {
557
+ onServiceInit() {
558
+ events.push('init')
559
+ }
560
+
561
+ onServiceDestroy() {
562
+ events.push('destroy')
563
+ }
564
+ }
565
+
566
+ const scoped = container.beginRequest('request-1')
567
+ await scoped.get(FullLifecycleService)
568
+ events.push('service-used')
569
+ await scoped.endRequest()
570
+
571
+ expect(events).toEqual(['init', 'service-used', 'destroy'])
572
+ })
573
+ })
574
+ })
575
+
576
+ // ============================================================================
577
+ // SECTION 5: INVALIDATION TESTS (Browser Mode)
578
+ // ============================================================================
579
+
580
+ describe('E2E Browser: Service Invalidation', () => {
581
+ let registry: Registry
582
+ let container: Container
583
+ let injectors: ReturnType<typeof getInjectors>
584
+
585
+ beforeEach(() => {
586
+ const setup = createTestSetup()
587
+ registry = setup.registry
588
+ container = setup.container
589
+ injectors = setup.injectors
590
+ })
591
+
592
+ afterEach(async () => {
593
+ await container.dispose()
594
+ })
595
+
596
+ describe('Basic invalidation', () => {
597
+ it('should destroy service when invalidated', async () => {
598
+ const destroySpy = vi.fn()
599
+ let instanceCount = 0
600
+
601
+ @Injectable({ scope: InjectableScope.Singleton, registry })
602
+ class CachingService implements OnServiceDestroy {
603
+ id = ++instanceCount
604
+
605
+ onServiceDestroy() {
606
+ destroySpy(this.id)
607
+ }
608
+ }
609
+
610
+ const service1 = await container.get(CachingService)
611
+ expect(service1.id).toBe(1)
612
+
613
+ await container.invalidate(service1)
614
+ expect(destroySpy).toHaveBeenCalledWith(1)
615
+
616
+ // Getting service again should create new instance
617
+ const service2 = await container.get(CachingService)
618
+ expect(service2.id).toBe(2)
619
+ expect(service2).not.toBe(service1)
620
+ })
621
+
622
+ it('should cascade invalidation to dependents', async () => {
623
+ const destroyOrder: string[] = []
624
+
625
+ @Injectable({ scope: InjectableScope.Singleton, registry })
626
+ class BaseService implements OnServiceDestroy {
627
+ onServiceDestroy() {
628
+ destroyOrder.push('BaseService')
629
+ }
630
+ }
631
+
632
+ @Injectable({ scope: InjectableScope.Singleton, registry })
633
+ class DependentService implements OnServiceDestroy {
634
+ private base = injectors.inject(BaseService)
635
+
636
+ async getBase() {
637
+ return this.base
638
+ }
639
+
640
+ onServiceDestroy() {
641
+ destroyOrder.push('DependentService')
642
+ }
643
+ }
644
+
645
+ const dependent = await container.get(DependentService)
646
+ await dependent.getBase() // ensure base is created
647
+
648
+ const base = await container.get(BaseService)
649
+ await container.invalidate(base)
650
+
651
+ // Both should be destroyed
652
+ expect(destroyOrder).toContain('BaseService')
653
+ expect(destroyOrder).toContain('DependentService')
654
+ })
655
+ })
656
+ })
657
+
658
+ // ============================================================================
659
+ // SECTION 6: ERROR HANDLING TESTS (Browser Mode)
660
+ // ============================================================================
661
+
662
+ describe('E2E Browser: Error Handling', () => {
663
+ let registry: Registry
664
+ let container: Container
665
+ let injectors: ReturnType<typeof getInjectors>
666
+
667
+ beforeEach(() => {
668
+ const setup = createTestSetup()
669
+ registry = setup.registry
670
+ container = setup.container
671
+ injectors = setup.injectors
672
+ })
673
+
674
+ afterEach(async () => {
675
+ try {
676
+ await container.dispose()
677
+ } catch {
678
+ // Ignore dispose errors in error handling tests
679
+ }
680
+ })
681
+
682
+ describe('Resolution errors', () => {
683
+ it('should throw when resolving request-scoped service outside request context', async () => {
684
+ @Injectable({ scope: InjectableScope.Request, registry })
685
+ class RequestOnlyService {}
686
+
687
+ await expect(container.get(RequestOnlyService)).rejects.toThrow()
688
+ })
689
+
690
+ it('should throw when using duplicate request IDs', async () => {
691
+ const scoped1 = container.beginRequest('duplicate-id')
692
+
693
+ expect(() => container.beginRequest('duplicate-id')).toThrow()
694
+
695
+ await scoped1.endRequest()
696
+ })
697
+ })
698
+
699
+ describe('Initialization errors', () => {
700
+ it('should propagate errors from onServiceInit', async () => {
701
+ @Injectable({ scope: InjectableScope.Singleton, registry })
702
+ class FailingInitService implements OnServiceInit {
703
+ onServiceInit() {
704
+ throw new Error('Init failed!')
705
+ }
706
+ }
707
+
708
+ await expect(container.get(FailingInitService)).rejects.toThrow(
709
+ 'Init failed!',
710
+ )
711
+ })
712
+
713
+ it('should propagate constructor errors', async () => {
714
+ @Injectable({ scope: InjectableScope.Singleton, registry })
715
+ class FailingConstructorService {
716
+ constructor() {
717
+ throw new Error('Constructor failed!')
718
+ }
719
+ }
720
+
721
+ await expect(container.get(FailingConstructorService)).rejects.toThrow(
722
+ 'Constructor failed!',
723
+ )
724
+ })
725
+ })
726
+ })
727
+
728
+ // ============================================================================
729
+ // SECTION 7: ADVANCED SCENARIOS (Browser Mode)
730
+ // ============================================================================
731
+
732
+ describe('E2E Browser: Advanced Scenarios', () => {
733
+ let registry: Registry
734
+ let container: Container
735
+ let injectors: ReturnType<typeof getInjectors>
736
+
737
+ beforeEach(() => {
738
+ const setup = createTestSetup()
739
+ registry = setup.registry
740
+ container = setup.container
741
+ injectors = setup.injectors
742
+ })
743
+
744
+ afterEach(async () => {
745
+ await container.dispose()
746
+ })
747
+
748
+ describe('tryGetSync functionality', () => {
749
+ it('should return null when service does not exist', () => {
750
+ @Injectable({ scope: InjectableScope.Singleton, registry })
751
+ class LazyService {}
752
+
753
+ const result = container.tryGetSync<LazyService>(LazyService)
754
+ expect(result).toBeNull()
755
+ })
756
+
757
+ it('should return instance when service exists', async () => {
758
+ @Injectable({ scope: InjectableScope.Singleton, registry })
759
+ class EagerService {
760
+ value = 42
761
+ }
762
+
763
+ // First create the service
764
+ await container.get(EagerService)
765
+
766
+ // Now sync get should work
767
+ const result = container.tryGetSync<EagerService>(EagerService)
768
+ expect(result).not.toBeNull()
769
+ expect(result!.value).toBe(42)
770
+ })
771
+ })
772
+
773
+ describe('Container self-registration', () => {
774
+ it('should allow injecting the Container itself', async () => {
775
+ @Injectable({ scope: InjectableScope.Singleton, registry })
776
+ class ContainerAwareService {
777
+ private container = injectors.inject(Container)
778
+
779
+ async getContainer() {
780
+ return this.container
781
+ }
782
+ }
783
+
784
+ const service = await container.get(ContainerAwareService)
785
+ const injectedContainer = await service.getContainer()
786
+
787
+ expect(injectedContainer).toBe(container)
788
+ })
789
+ })
790
+ })