@forgeax/engine-plugin 0.1.21 → 0.1.24

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 (37) hide show
  1. package/README.md +94 -10
  2. package/dist/__tests__/composition-contract.test-d.d.ts +2 -0
  3. package/dist/__tests__/composition-contract.test-d.d.ts.map +1 -0
  4. package/dist/__tests__/composition.integration.test.d.ts +2 -0
  5. package/dist/__tests__/composition.integration.test.d.ts.map +1 -0
  6. package/dist/__tests__/public-api.test-d.d.ts +2 -0
  7. package/dist/__tests__/public-api.test-d.d.ts.map +1 -0
  8. package/dist/__tests__/realm-loader.integration.test.d.ts +2 -0
  9. package/dist/__tests__/realm-loader.integration.test.d.ts.map +1 -0
  10. package/dist/browser.d.ts +2 -0
  11. package/dist/browser.d.ts.map +1 -1
  12. package/dist/browser.mjs +548 -1
  13. package/dist/browser.mjs.map +1 -1
  14. package/dist/composition.d.ts +73 -0
  15. package/dist/composition.d.ts.map +1 -0
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.mjs +560 -8
  19. package/dist/index.mjs.map +1 -1
  20. package/dist/inspection.d.ts +26 -0
  21. package/dist/inspection.d.ts.map +1 -0
  22. package/dist/loader.d.ts +51 -3
  23. package/dist/loader.d.ts.map +1 -1
  24. package/dist/loader.mjs +12 -7
  25. package/dist/loader.mjs.map +1 -1
  26. package/package.json +2 -2
  27. package/src/__tests__/browser-entry.test.ts +1 -0
  28. package/src/__tests__/composition-contract.test-d.ts +74 -0
  29. package/src/__tests__/composition.integration.test.ts +1347 -0
  30. package/src/__tests__/public-api.test-d.ts +70 -0
  31. package/src/__tests__/realm-loader.integration.test.ts +56 -0
  32. package/src/browser.ts +18 -0
  33. package/src/composition.ts +642 -0
  34. package/src/index.ts +18 -0
  35. package/src/inspection.ts +160 -0
  36. package/src/loader.ts +82 -13
  37. package/dist/.tsbuildinfo +0 -1
@@ -0,0 +1,1347 @@
1
+ import { Context, type Plugin } from '@deepseek-ai/cordis';
2
+ import { describe, expect, it } from 'vitest';
3
+ import {
4
+ bootstrapCatalogLoader,
5
+ CatalogLoaderError,
6
+ definePluginGroup,
7
+ installCatalogLoader,
8
+ PluginCompositionError,
9
+ projectPluginEntries,
10
+ usePlugin,
11
+ } from '../index.js';
12
+
13
+ function assertCatalogFailure(
14
+ error: unknown,
15
+ code: CatalogLoaderError['code'],
16
+ subject: string,
17
+ expectedOwner: string,
18
+ recoveryAction: string,
19
+ ): CatalogLoaderError {
20
+ expect(error).toBeInstanceOf(CatalogLoaderError);
21
+ if (!(error instanceof CatalogLoaderError)) throw new Error('expected CatalogLoaderError');
22
+ const failure = error;
23
+ expect(failure.code).toBe(code);
24
+ expect(failure.expected).toContain(expectedOwner);
25
+ expect(failure.hint).toContain(recoveryAction);
26
+ switch (failure.code) {
27
+ case 'plugin-catalog-missing':
28
+ expect(failure.detail.name).toBe(subject);
29
+ break;
30
+ case 'plugin-realm-mismatch':
31
+ expect(failure.detail.name).toBe(subject);
32
+ expect(failure.detail.actual).toBe('host');
33
+ expect(failure.detail.expected).toBe('engine');
34
+ break;
35
+ case 'plugin-entry-realm-mixed':
36
+ expect(failure.detail.group).toBe(subject);
37
+ expect(failure.detail.expected).toBe('engine');
38
+ break;
39
+ case 'plugin-realm-unsupported':
40
+ expect(failure.detail.realm).toBe(subject);
41
+ break;
42
+ case 'plugin-catalog-digest-mismatch':
43
+ expect(failure.detail.actual).toBe(subject);
44
+ break;
45
+ }
46
+ return failure;
47
+ }
48
+
49
+ function assertCompositionFailure(
50
+ error: unknown,
51
+ code: PluginCompositionError['code'],
52
+ subject: string,
53
+ expectedOwner: string,
54
+ recoveryAction: string,
55
+ ): void {
56
+ expect(error).toBeInstanceOf(PluginCompositionError);
57
+ if (!(error instanceof PluginCompositionError))
58
+ throw new Error('expected PluginCompositionError');
59
+ const failure = error;
60
+ expect(failure.code).toBe(code);
61
+ expect(failure.expected).toContain(expectedOwner);
62
+ expect(failure.hint).toContain(recoveryAction);
63
+ switch (failure.code) {
64
+ case 'plugin-config-invalid':
65
+ expect(failure.detail.plugin).toBe(subject);
66
+ break;
67
+ case 'plugin-group-child-failed':
68
+ expect(failure.detail.child).toBe(subject);
69
+ break;
70
+ case 'plugin-group-dependency-cycle':
71
+ expect(failure.detail.path).toEqual(expect.arrayContaining([subject]));
72
+ break;
73
+ case 'plugin-group-key-duplicate':
74
+ expect(failure.detail.key).toBe(subject);
75
+ break;
76
+ case 'plugin-group-key-required':
77
+ expect(failure.detail.plugin).toBe(subject);
78
+ break;
79
+ case 'plugin-group-provider-missing':
80
+ expect(failure.detail.service).toBe(subject);
81
+ break;
82
+ }
83
+ }
84
+
85
+ describe('definePluginGroup', () => {
86
+ it('passes the provider-owned service value to an injected consumer', async () => {
87
+ const observed: number[] = [];
88
+ const provider: Plugin = {
89
+ name: 'answer-provider',
90
+ provide: 'answer',
91
+ apply(ctx) {
92
+ ctx.provide('answer', 42);
93
+ },
94
+ };
95
+ const consumer: Plugin = {
96
+ name: 'answer-consumer',
97
+ inject: ['answer'],
98
+ apply(ctx) {
99
+ observed.push((ctx as Context & { readonly answer: number }).answer);
100
+ },
101
+ };
102
+ const group = definePluginGroup({
103
+ name: 'provider-value-group',
104
+ children: () => [usePlugin(consumer), usePlugin(provider)],
105
+ });
106
+ const ctx = new Context();
107
+
108
+ await ctx.plugin(group);
109
+ expect(observed).toEqual([42]);
110
+ await ctx.fiber.dispose();
111
+ });
112
+
113
+ it('keeps the old child as LKG until a replacement child settles', async () => {
114
+ const events: string[] = [];
115
+ let failReplacement = true;
116
+ const oldProvider: Plugin = {
117
+ name: 'old-provider',
118
+ provide: 'answer',
119
+ apply(ctx) {
120
+ ctx.provide('answer', 1);
121
+ events.push('old:apply');
122
+ ctx.effect(() => () => events.push('old:dispose'));
123
+ },
124
+ };
125
+ const replacement: Plugin = {
126
+ name: 'replacement-provider',
127
+ async apply(ctx) {
128
+ events.push('replacement:apply');
129
+ if (failReplacement) throw new Error('replacement failed');
130
+ ctx.effect(() => () => events.push('replacement:dispose'));
131
+ },
132
+ };
133
+ let children = [usePlugin(oldProvider, undefined, { key: 'provider' })];
134
+ const group = definePluginGroup({ name: 'replacement-lkg-group', children: () => children });
135
+ const ctx = new Context();
136
+ const fiber = await ctx.plugin(group);
137
+ expect(events).toEqual(['old:apply']);
138
+ expect(ctx.get('answer')).toBe(1);
139
+
140
+ children = [usePlugin(replacement, undefined, { key: 'provider' })];
141
+ let failure: unknown;
142
+ try {
143
+ await fiber.update({});
144
+ } catch (error: unknown) {
145
+ failure = error;
146
+ }
147
+ assertCompositionFailure(
148
+ failure,
149
+ 'plugin-group-child-failed',
150
+ 'replacement-provider',
151
+ 'owning Group',
152
+ 'repair the child',
153
+ );
154
+ expect(events).toEqual(['old:apply', 'replacement:apply']);
155
+ expect(ctx.get('answer')).toBe(1);
156
+
157
+ failReplacement = false;
158
+ await fiber.update({});
159
+ expect(events).toEqual(['old:apply', 'replacement:apply', 'replacement:apply', 'old:dispose']);
160
+ expect(ctx.get('answer')).toBeUndefined();
161
+ await ctx.fiber.dispose();
162
+ expect(events).toEqual([
163
+ 'old:apply',
164
+ 'replacement:apply',
165
+ 'replacement:apply',
166
+ 'old:dispose',
167
+ 'replacement:dispose',
168
+ ]);
169
+ });
170
+
171
+ it('does not infer a provider from a matching child name', async () => {
172
+ let applied = 0;
173
+ const namedChild: Plugin = {
174
+ name: 'named-service',
175
+ apply: () => {
176
+ applied += 1;
177
+ },
178
+ };
179
+ const dependent: Plugin = {
180
+ name: 'dependent-on-name',
181
+ inject: ['named-service'],
182
+ apply: () => {
183
+ applied += 1;
184
+ },
185
+ };
186
+ const group = definePluginGroup({
187
+ name: 'no-name-provider-inference-group',
188
+ children: () => [usePlugin(dependent), usePlugin(namedChild)],
189
+ });
190
+
191
+ let failure: unknown;
192
+ try {
193
+ await new Context().plugin(group);
194
+ } catch (error: unknown) {
195
+ failure = error;
196
+ }
197
+ assertCompositionFailure(
198
+ failure,
199
+ 'plugin-group-provider-missing',
200
+ 'named-service',
201
+ 'inject/provide dependency',
202
+ 'provide the service',
203
+ );
204
+ expect(applied).toBe(0);
205
+ });
206
+
207
+ it('hands off a healthy replacement that provides the same service token', async () => {
208
+ const observed: number[] = [];
209
+ const events: string[] = [];
210
+ const provider = (name: string, value: number): Plugin => ({
211
+ name,
212
+ provide: 'answer',
213
+ apply(ctx) {
214
+ ctx.provide('answer', value);
215
+ events.push(`${name}:apply`);
216
+ ctx.effect(() => () => events.push(`${name}:dispose`));
217
+ },
218
+ });
219
+ const consumer: Plugin = {
220
+ name: 'replacement-consumer',
221
+ inject: ['answer'],
222
+ apply(ctx) {
223
+ observed.push(Number(ctx.get('answer')));
224
+ },
225
+ };
226
+ let children = [
227
+ usePlugin(consumer),
228
+ usePlugin(provider('old-answer', 1), undefined, { key: 'provider' }),
229
+ ];
230
+ const group = definePluginGroup({ name: 'same-service-swap-group', children: () => children });
231
+ const ctx = new Context();
232
+ const fiber = await ctx.plugin(group);
233
+ expect(observed.at(-1)).toBe(1);
234
+
235
+ children = [
236
+ usePlugin(consumer),
237
+ usePlugin(provider('new-answer', 2), undefined, { key: 'provider' }),
238
+ ];
239
+ await fiber.update({});
240
+ expect(observed.at(-1)).toBe(2);
241
+ expect(ctx.get('answer')).toBe(2);
242
+ expect(events).toContain('old-answer:dispose');
243
+ expect(events).toContain('new-answer:apply');
244
+ await ctx.fiber.dispose();
245
+ });
246
+
247
+ it('hands off a provider when only its stable key changes', async () => {
248
+ const observed: number[] = [];
249
+ const events: string[] = [];
250
+ const provider: Plugin.Object<{ readonly value: number }> = {
251
+ name: 'keyed-answer',
252
+ provide: 'answer',
253
+ apply(ctx, config) {
254
+ ctx.provide('answer', config.value);
255
+ events.push(`apply:${config.value}`);
256
+ ctx.effect(() => () => events.push(`dispose:${config.value}`));
257
+ },
258
+ };
259
+ const consumer: Plugin = {
260
+ name: 'key-change-consumer',
261
+ inject: ['answer'],
262
+ apply(ctx) {
263
+ observed.push(Number(ctx.get('answer')));
264
+ },
265
+ };
266
+ let children = [
267
+ usePlugin(consumer),
268
+ usePlugin(provider, { value: 1 }, { key: 'old-provider' }),
269
+ ];
270
+ const group = definePluginGroup({ name: 'key-change-group', children: () => children });
271
+ const ctx = new Context();
272
+ const fiber = await ctx.plugin(group);
273
+
274
+ children = [usePlugin(consumer), usePlugin(provider, { value: 2 }, { key: 'new-provider' })];
275
+ await fiber.update({});
276
+ expect(observed.at(-1)).toBe(2);
277
+ expect(events).toEqual(['apply:1', 'dispose:1', 'apply:2']);
278
+ await ctx.fiber.dispose();
279
+ });
280
+
281
+ it('restores a removed-key provider after a failed same-service handoff', async () => {
282
+ const events: string[] = [];
283
+ let fail = false;
284
+ const provider: Plugin.Object<{ readonly value: number }> = {
285
+ name: 'keyed-flaky-answer',
286
+ provide: 'answer',
287
+ async apply(ctx, config) {
288
+ events.push(`apply:${config.value}`);
289
+ if (fail && config.value === 2) throw new Error('temporary provider failure');
290
+ ctx.provide('answer', config.value);
291
+ ctx.effect(() => () => events.push(`dispose:${config.value}`));
292
+ },
293
+ };
294
+ let children = [usePlugin(provider, { value: 1 }, { key: 'old-provider' })];
295
+ const group = definePluginGroup({ name: 'key-change-lkg-group', children: () => children });
296
+ const ctx = new Context();
297
+ const fiber = await ctx.plugin(group);
298
+
299
+ children = [usePlugin(provider, { value: 2 }, { key: 'new-provider' })];
300
+ let failure: unknown;
301
+ fail = true;
302
+ try {
303
+ await fiber.update({});
304
+ } catch (error: unknown) {
305
+ failure = error;
306
+ }
307
+ assertCompositionFailure(
308
+ failure,
309
+ 'plugin-group-child-failed',
310
+ 'keyed-flaky-answer',
311
+ 'owning Group',
312
+ 'repair the child',
313
+ );
314
+ expect(ctx.get('answer')).toBe(1);
315
+ expect(events).toEqual(['apply:1', 'dispose:1', 'apply:2', 'apply:1']);
316
+
317
+ fail = false;
318
+ await fiber.update({});
319
+ expect(ctx.get('answer')).toBe(2);
320
+ await ctx.fiber.dispose();
321
+ });
322
+
323
+ it('restores a same-service provider before rejecting and can retry the replacement', async () => {
324
+ const events: string[] = [];
325
+ let failReplacement = true;
326
+ const oldProvider: Plugin = {
327
+ name: 'stable-answer',
328
+ provide: 'answer',
329
+ apply(ctx) {
330
+ ctx.provide('answer', 1);
331
+ events.push('stable:apply');
332
+ ctx.effect(() => () => events.push('stable:dispose'));
333
+ },
334
+ };
335
+ const replacement: Plugin = {
336
+ name: 'flaky-answer',
337
+ provide: 'answer',
338
+ async apply(ctx) {
339
+ events.push('flaky:apply');
340
+ if (failReplacement) throw new Error('replacement failed');
341
+ ctx.provide('answer', 2);
342
+ ctx.effect(() => () => events.push('flaky:dispose'));
343
+ },
344
+ };
345
+ let children = [usePlugin(oldProvider, undefined, { key: 'provider' })];
346
+ const group = definePluginGroup({ name: 'same-service-lkg-group', children: () => children });
347
+ const ctx = new Context();
348
+ const fiber = await ctx.plugin(group);
349
+ expect(ctx.get('answer')).toBe(1);
350
+
351
+ children = [usePlugin(replacement, undefined, { key: 'provider' })];
352
+ let failure: unknown;
353
+ try {
354
+ await fiber.update({});
355
+ } catch (error: unknown) {
356
+ failure = error;
357
+ }
358
+ assertCompositionFailure(
359
+ failure,
360
+ 'plugin-group-child-failed',
361
+ 'flaky-answer',
362
+ 'owning Group',
363
+ 'repair the child',
364
+ );
365
+ expect(ctx.get('answer')).toBe(1);
366
+ expect(events.at(-1)).toBe('stable:apply');
367
+
368
+ failReplacement = false;
369
+ await fiber.update({});
370
+ expect(ctx.get('answer')).toBe(2);
371
+ expect(events).toContain('stable:dispose');
372
+ await ctx.fiber.dispose();
373
+ });
374
+
375
+ it('does not let a removed Group provider satisfy its remaining consumer', async () => {
376
+ const events: string[] = [];
377
+ const provider: Plugin = {
378
+ name: 'group-answer',
379
+ provide: 'answer',
380
+ apply(ctx) {
381
+ ctx.provide('answer', 1);
382
+ events.push('provider:apply');
383
+ ctx.effect(() => () => events.push('provider:dispose'));
384
+ },
385
+ };
386
+ const consumer: Plugin = {
387
+ name: 'remaining-consumer',
388
+ inject: ['answer'],
389
+ apply: () => {
390
+ events.push('consumer:apply');
391
+ },
392
+ };
393
+ let children = [usePlugin(provider), usePlugin(consumer)];
394
+ const group = definePluginGroup({ name: 'removed-provider-group', children: () => children });
395
+ const ctx = new Context();
396
+ const fiber = await ctx.plugin(group);
397
+ expect(events).toEqual(['provider:apply', 'consumer:apply']);
398
+
399
+ children = [usePlugin(consumer)];
400
+ let failure: unknown;
401
+ try {
402
+ await fiber.update({});
403
+ } catch (error: unknown) {
404
+ failure = error;
405
+ }
406
+ assertCompositionFailure(
407
+ failure,
408
+ 'plugin-group-provider-missing',
409
+ 'answer',
410
+ 'inject/provide dependency',
411
+ 'provide the service',
412
+ );
413
+ expect(events).toEqual(['provider:apply', 'consumer:apply']);
414
+ expect(ctx.get('answer')).toBe(1);
415
+ await ctx.fiber.dispose();
416
+ });
417
+
418
+ it('settles the old service before rejecting a failed config update', async () => {
419
+ const events: string[] = [];
420
+ const configurable: Plugin.Object<{ readonly value: number }> = {
421
+ name: 'configurable-answer',
422
+ provide: 'answer',
423
+ async apply(ctx, config) {
424
+ events.push(`apply:${config.value}`);
425
+ if (config.value === 2) throw new Error('invalid answer update');
426
+ ctx.provide('answer', config.value);
427
+ ctx.effect(() => () => events.push(`dispose:${config.value}`));
428
+ },
429
+ };
430
+ let children = [usePlugin(configurable, { value: 1 })];
431
+ const group = definePluginGroup({ name: 'config-lkg-group', children: () => children });
432
+ const ctx = new Context();
433
+ const fiber = await ctx.plugin(group);
434
+ expect(ctx.get('answer')).toBe(1);
435
+
436
+ children = [usePlugin(configurable, { value: 2 })];
437
+ let failure: unknown;
438
+ try {
439
+ await fiber.update({});
440
+ } catch (error: unknown) {
441
+ failure = error;
442
+ }
443
+ assertCompositionFailure(
444
+ failure,
445
+ 'plugin-group-child-failed',
446
+ 'configurable-answer',
447
+ 'owning Group',
448
+ 'repair the child',
449
+ );
450
+ await fiber.await();
451
+ expect(ctx.get('answer')).toBe(1);
452
+ expect(events).toEqual(['apply:1', 'dispose:1', 'apply:2', 'apply:1']);
453
+ await ctx.fiber.dispose();
454
+ });
455
+
456
+ it('settles children through native Fiber dependencies rather than array order', async () => {
457
+ const events: string[] = [];
458
+ const provider: Plugin = {
459
+ name: 'provider',
460
+ provide: 'world',
461
+ apply(ctx) {
462
+ events.push('provider:apply');
463
+ ctx.provide('world', {});
464
+ ctx.effect(() => () => events.push('provider:dispose'));
465
+ },
466
+ };
467
+ const consumer: Plugin = {
468
+ name: 'consumer',
469
+ inject: ['world'],
470
+ apply(ctx) {
471
+ events.push('consumer:apply');
472
+ ctx.effect(() => () => events.push('consumer:dispose'));
473
+ },
474
+ };
475
+ const group = definePluginGroup({
476
+ name: 'ordered-by-services',
477
+ children: () => [usePlugin(consumer), usePlugin(provider)],
478
+ });
479
+ const ctx = new Context();
480
+
481
+ await ctx.plugin(group);
482
+ expect(events).toEqual(['provider:apply', 'consumer:apply']);
483
+ await ctx.fiber.dispose();
484
+ expect(events).toEqual([
485
+ 'provider:apply',
486
+ 'consumer:apply',
487
+ 'consumer:dispose',
488
+ 'provider:dispose',
489
+ ]);
490
+ });
491
+
492
+ it('starts independent children before any one child settles', async () => {
493
+ const events: string[] = [];
494
+ let resolveFirst!: () => void;
495
+ let resolveSecond!: () => void;
496
+ const firstSettled = new Promise<void>((resolve) => {
497
+ resolveFirst = resolve;
498
+ });
499
+ const secondSettled = new Promise<void>((resolve) => {
500
+ resolveSecond = resolve;
501
+ });
502
+ let started = 0;
503
+ let resolveBothStarted!: () => void;
504
+ const bothStarted = new Promise<void>((resolve) => {
505
+ resolveBothStarted = resolve;
506
+ });
507
+ const first = {
508
+ name: 'first-independent',
509
+ async apply() {
510
+ events.push('first-independent:start');
511
+ started += 1;
512
+ if (started === 2) resolveBothStarted();
513
+ await firstSettled;
514
+ events.push('first-independent:settled');
515
+ },
516
+ } satisfies Plugin;
517
+ const second = {
518
+ name: 'second-independent',
519
+ async apply() {
520
+ events.push('second-independent:start');
521
+ started += 1;
522
+ if (started === 2) resolveBothStarted();
523
+ await secondSettled;
524
+ events.push('second-independent:settled');
525
+ },
526
+ } satisfies Plugin;
527
+ const group = definePluginGroup({
528
+ name: 'parallel-start-group',
529
+ children: () => [usePlugin(first), usePlugin(second)],
530
+ });
531
+ const ctx = new Context();
532
+ const groupFiber = ctx.plugin(group);
533
+
534
+ await bothStarted;
535
+ expect(events).toEqual(['first-independent:start', 'second-independent:start']);
536
+ expect(events).not.toContain('first-independent:settled');
537
+ resolveFirst();
538
+ await firstSettled;
539
+ resolveSecond();
540
+ await groupFiber;
541
+ expect(events).toEqual([
542
+ 'first-independent:start',
543
+ 'second-independent:start',
544
+ 'first-independent:settled',
545
+ 'second-independent:settled',
546
+ ]);
547
+ await ctx.fiber.dispose();
548
+ });
549
+
550
+ it('reports the child whose Fiber actually failed', async () => {
551
+ const failing: Plugin = {
552
+ name: 'first-failing-child',
553
+ apply() {
554
+ throw new Error('first child failed');
555
+ },
556
+ };
557
+ const sibling: Plugin = {
558
+ name: 'last-sibling-child',
559
+ apply(ctx) {
560
+ ctx.effect(() => () => undefined);
561
+ },
562
+ };
563
+ const group = definePluginGroup({
564
+ name: 'failure-owner-group',
565
+ children: () => [usePlugin(failing), usePlugin(sibling)],
566
+ });
567
+
568
+ let failure: unknown;
569
+ try {
570
+ await new Context().plugin(group);
571
+ } catch (error: unknown) {
572
+ failure = error;
573
+ }
574
+ assertCompositionFailure(
575
+ failure,
576
+ 'plugin-group-child-failed',
577
+ 'first-failing-child',
578
+ 'owning Group',
579
+ 'repair the child',
580
+ );
581
+ });
582
+
583
+ it('cleans failed candidates in reverse and propagates rollback failures', async () => {
584
+ const events: string[] = [];
585
+ let restoreShouldFail = false;
586
+ let resolveFirst!: () => void;
587
+ let resolveSecond!: () => void;
588
+ const firstGate = new Promise<void>((resolve) => {
589
+ resolveFirst = resolve;
590
+ });
591
+ const secondGate = new Promise<void>((resolve) => {
592
+ resolveSecond = resolve;
593
+ });
594
+ let resolveSecondDisposeStarted!: () => void;
595
+ const secondDisposeStarted = new Promise<void>((resolve) => {
596
+ resolveSecondDisposeStarted = resolve;
597
+ });
598
+ let resolveFirstDisposeStarted!: () => void;
599
+ const firstDisposeStarted = new Promise<void>((resolve) => {
600
+ resolveFirstDisposeStarted = resolve;
601
+ });
602
+ const oldProvider: Plugin = {
603
+ name: 'old-rollback-provider',
604
+ provide: 'answer',
605
+ apply(ctx) {
606
+ if (restoreShouldFail) throw new Error('old provider restore failed');
607
+ ctx.provide('answer', 1);
608
+ },
609
+ };
610
+ const first: Plugin = {
611
+ name: 'first-candidate',
612
+ apply(ctx) {
613
+ ctx.effect(() => async () => {
614
+ events.push('first:dispose:start');
615
+ resolveFirstDisposeStarted();
616
+ await firstGate;
617
+ events.push('first:dispose:end');
618
+ });
619
+ },
620
+ };
621
+ const second: Plugin = {
622
+ name: 'second-candidate',
623
+ apply(ctx) {
624
+ ctx.effect(() => async () => {
625
+ events.push('second:dispose:start');
626
+ resolveSecondDisposeStarted();
627
+ await secondGate;
628
+ events.push('second:dispose:end');
629
+ });
630
+ },
631
+ };
632
+ const failing: Plugin = {
633
+ name: 'rollback-trigger',
634
+ provide: 'answer',
635
+ apply() {
636
+ throw new Error('candidate activation failed');
637
+ },
638
+ };
639
+ let children = [usePlugin(oldProvider, undefined, { key: 'old-provider' })];
640
+ const group = definePluginGroup({
641
+ name: 'reverse-cleanup-group',
642
+ children: () => children,
643
+ });
644
+ const ctx = new Context();
645
+ const groupFiber = await ctx.plugin(group);
646
+ restoreShouldFail = true;
647
+ children = [usePlugin(first), usePlugin(second), usePlugin(failing)];
648
+
649
+ const update = Promise.resolve(groupFiber.update({}));
650
+ await secondDisposeStarted;
651
+ expect(events).toEqual(['second:dispose:start']);
652
+ resolveSecond();
653
+ await firstDisposeStarted;
654
+ expect(events).toEqual(['second:dispose:start', 'second:dispose:end', 'first:dispose:start']);
655
+ resolveFirst();
656
+ await update.catch((error: unknown) => {
657
+ expect(error).toBeInstanceOf(AggregateError);
658
+ if (!(error instanceof AggregateError)) return;
659
+ expect(error.errors).toEqual(
660
+ expect.arrayContaining([
661
+ expect.objectContaining({ message: 'old provider restore failed' }),
662
+ ]),
663
+ );
664
+ expect(
665
+ error.errors.some(
666
+ (item: unknown) =>
667
+ item instanceof PluginCompositionError &&
668
+ item.code === 'plugin-group-child-failed' &&
669
+ item.detail.child === 'rollback-trigger',
670
+ ),
671
+ ).toBe(true);
672
+ });
673
+ expect(events).toEqual([
674
+ 'second:dispose:start',
675
+ 'second:dispose:end',
676
+ 'first:dispose:start',
677
+ 'first:dispose:end',
678
+ ]);
679
+ });
680
+
681
+ it('rejects repeated plugin references without a stable key before side effects', async () => {
682
+ let applied = 0;
683
+ const child: Plugin = {
684
+ name: 'repeated-child',
685
+ apply: () => {
686
+ applied += 1;
687
+ },
688
+ };
689
+ const group = definePluginGroup({
690
+ name: 'missing-key-group',
691
+ children: () => [usePlugin(child), usePlugin(child)],
692
+ });
693
+
694
+ let failure: unknown;
695
+ try {
696
+ await new Context().plugin(group);
697
+ } catch (error: unknown) {
698
+ failure = error;
699
+ }
700
+ assertCompositionFailure(
701
+ failure,
702
+ 'plugin-group-key-required',
703
+ 'repeated-child',
704
+ 'stable child key',
705
+ 'assign a unique key',
706
+ );
707
+ expect(applied).toBe(0);
708
+ });
709
+
710
+ it('rolls back already-active children when a later child fails', async () => {
711
+ const events: string[] = [];
712
+ const healthy: Plugin = {
713
+ name: 'healthy',
714
+ apply(ctx) {
715
+ events.push('healthy:apply');
716
+ ctx.effect(() => () => events.push('healthy:dispose'));
717
+ },
718
+ };
719
+ const failing: Plugin = {
720
+ name: 'failing',
721
+ apply() {
722
+ events.push('failing:apply');
723
+ throw new Error('child activation failed');
724
+ },
725
+ };
726
+ const group = definePluginGroup({
727
+ name: 'rollback-group',
728
+ children: () => [usePlugin(healthy), usePlugin(failing)],
729
+ });
730
+
731
+ let failure: unknown;
732
+ try {
733
+ await new Context().plugin(group);
734
+ } catch (error: unknown) {
735
+ failure = error;
736
+ }
737
+ assertCompositionFailure(
738
+ failure,
739
+ 'plugin-group-child-failed',
740
+ 'failing',
741
+ 'owning Group',
742
+ 'repair the child',
743
+ );
744
+ expect(events).toEqual(['healthy:apply', 'failing:apply', 'healthy:dispose']);
745
+ });
746
+
747
+ it('rejects invalid Plugin.Config before the child side effect', async () => {
748
+ let applied = 0;
749
+ const configured: Plugin.Function<{ readonly speed: number }> = Object.assign(
750
+ function configured(_ctx: Context, _config: { readonly speed: number }) {
751
+ applied += 1;
752
+ },
753
+ {
754
+ Config: {
755
+ '~standard': {
756
+ version: 1 as const,
757
+ vendor: 'forgeax-t1-1',
758
+ validate(value: unknown) {
759
+ if (typeof value !== 'object' || value === null) {
760
+ return { issues: [{ message: 'config must be an object' }] };
761
+ }
762
+ const candidate = value as Record<string, unknown>;
763
+ if (Object.keys(candidate).length !== 1 || typeof candidate.speed !== 'number') {
764
+ return { issues: [{ message: 'config requires only numeric speed' }] };
765
+ }
766
+ return { value: { speed: candidate.speed } };
767
+ },
768
+ },
769
+ },
770
+ },
771
+ );
772
+ const group = definePluginGroup({
773
+ name: 'invalid-config-group',
774
+ children: () => [
775
+ usePlugin(configured, { speed: 'bad' } as unknown as { readonly speed: number }),
776
+ ],
777
+ });
778
+
779
+ let failure: unknown;
780
+ try {
781
+ await new Context().plugin(group);
782
+ } catch (error: unknown) {
783
+ failure = error;
784
+ }
785
+ assertCompositionFailure(
786
+ failure,
787
+ 'plugin-config-invalid',
788
+ 'configured',
789
+ 'Plugin.Config',
790
+ 'provide a valid config',
791
+ );
792
+ expect(applied).toBe(0);
793
+ });
794
+
795
+ it('validates every candidate before releasing the current service owner', async () => {
796
+ const events: string[] = [];
797
+ const provider: Plugin.Object<{ readonly value: number }> = {
798
+ name: 'preflight-provider',
799
+ provide: 'answer',
800
+ apply(ctx, config) {
801
+ ctx.provide('answer', config.value);
802
+ events.push(`apply:${config.value}`);
803
+ ctx.effect(() => () => events.push(`dispose:${config.value}`));
804
+ },
805
+ };
806
+ const invalid: Plugin.Function<{ readonly value: number }> = Object.assign(
807
+ function invalidCandidate() {
808
+ events.push('invalid:apply');
809
+ },
810
+ {
811
+ Config: {
812
+ '~standard': {
813
+ version: 1 as const,
814
+ vendor: 'forgeax-t1-2',
815
+ validate(value: unknown) {
816
+ if (typeof value !== 'object' || value === null) {
817
+ return { issues: [{ message: 'candidate config must be an object' }] };
818
+ }
819
+ return { issues: [{ message: 'candidate config is rejected' }] };
820
+ },
821
+ },
822
+ },
823
+ },
824
+ );
825
+ let children = [usePlugin(provider, { value: 1 }, { key: 'old-provider' })];
826
+ const group = definePluginGroup({ name: 'preflight-group', children: () => children });
827
+ const ctx = new Context();
828
+ const fiber = await ctx.plugin(group);
829
+
830
+ children = [
831
+ usePlugin(provider, { value: 2 }, { key: 'new-provider' }),
832
+ usePlugin(invalid, { value: 3 }, { key: 'invalid' }),
833
+ ];
834
+ let failure: unknown;
835
+ try {
836
+ await fiber.update({});
837
+ } catch (error: unknown) {
838
+ failure = error;
839
+ }
840
+ assertCompositionFailure(
841
+ failure,
842
+ 'plugin-config-invalid',
843
+ 'invalidCandidate',
844
+ 'Plugin.Config',
845
+ 'provide a valid config',
846
+ );
847
+ expect(events).toEqual(['apply:1']);
848
+ expect(ctx.get('answer')).toBe(1);
849
+ await ctx.fiber.dispose();
850
+ });
851
+
852
+ it('updates a function-valued config on the same child Fiber', async () => {
853
+ const calls: number[] = [];
854
+ const childFibers: unknown[] = [];
855
+ const configurable: Plugin.Object<{ readonly callback: () => number }> = {
856
+ name: 'function-config-child',
857
+ apply(ctx, config) {
858
+ childFibers.push(ctx.fiber);
859
+ calls.push(config.callback());
860
+ },
861
+ };
862
+ let children = [usePlugin(configurable, { callback: () => 1 })];
863
+ const group = definePluginGroup({ name: 'function-config-group', children: () => children });
864
+ const ctx = new Context();
865
+ const fiber = await ctx.plugin(group);
866
+
867
+ children = [usePlugin(configurable, { callback: () => 2 })];
868
+ await fiber.update({});
869
+ expect(calls).toEqual([1, 2]);
870
+ expect(childFibers[0]).toBe(childFibers[1]);
871
+ await ctx.fiber.dispose();
872
+ });
873
+
874
+ it('does not retain candidates when an invalid runtime child stops creation', async () => {
875
+ const events: string[] = [];
876
+ const baseline: Plugin = {
877
+ name: 'baseline-child',
878
+ apply(ctx) {
879
+ ctx.effect(() => () => events.push('baseline:dispose'));
880
+ },
881
+ };
882
+ const first: Plugin = {
883
+ name: 'first-candidate',
884
+ apply(ctx) {
885
+ events.push('first:apply');
886
+ ctx.effect(() => () => events.push('first:dispose'));
887
+ },
888
+ };
889
+ const invalid = { name: 'invalid-runtime-child' } as unknown as Plugin;
890
+ const last: Plugin = {
891
+ name: 'last-candidate',
892
+ apply(ctx) {
893
+ events.push('last:apply');
894
+ ctx.effect(() => () => events.push('last:dispose'));
895
+ },
896
+ };
897
+ let children = [usePlugin(baseline)];
898
+ const group = definePluginGroup({
899
+ name: 'candidate-registration-group',
900
+ children: () => children,
901
+ });
902
+ const ctx = new Context();
903
+ const fiber = await ctx.plugin(group);
904
+
905
+ children = [usePlugin(first), usePlugin(invalid), usePlugin(last)];
906
+ let failure: unknown;
907
+ try {
908
+ await fiber.update({});
909
+ } catch (error: unknown) {
910
+ failure = error;
911
+ }
912
+ assertCompositionFailure(
913
+ failure,
914
+ 'plugin-group-child-failed',
915
+ 'invalid-runtime-child',
916
+ 'owning Group',
917
+ 'repair the child',
918
+ );
919
+ expect(events).toEqual([]);
920
+ await ctx.fiber.dispose();
921
+ expect(events).toEqual(['baseline:dispose']);
922
+ });
923
+
924
+ it('rejects duplicate stable keys before creating child side effects', async () => {
925
+ let applied = 0;
926
+ const child: Plugin = {
927
+ name: 'child',
928
+ apply: () => {
929
+ applied += 1;
930
+ },
931
+ };
932
+ const group = definePluginGroup({
933
+ name: 'duplicate-key-group',
934
+ children: () => [
935
+ usePlugin(child, undefined, { key: 'same' }),
936
+ usePlugin(child, undefined, { key: 'same' }),
937
+ ],
938
+ });
939
+
940
+ let failure: unknown;
941
+ try {
942
+ await new Context().plugin(group);
943
+ } catch (error: unknown) {
944
+ failure = error;
945
+ }
946
+ assertCompositionFailure(
947
+ failure,
948
+ 'plugin-group-key-duplicate',
949
+ 'same',
950
+ 'stable child key',
951
+ 'assign a unique key',
952
+ );
953
+ expect(applied).toBe(0);
954
+ });
955
+
956
+ it('rejects a missing provider before activating the dependent child', async () => {
957
+ let applied = 0;
958
+ const dependent: Plugin = {
959
+ name: 'dependent',
960
+ inject: ['missing-service'],
961
+ apply: () => {
962
+ applied += 1;
963
+ },
964
+ };
965
+ const group = definePluginGroup({
966
+ name: 'missing-provider-group',
967
+ children: () => [usePlugin(dependent)],
968
+ });
969
+
970
+ let failure: unknown;
971
+ try {
972
+ await new Context().plugin(group);
973
+ } catch (error: unknown) {
974
+ failure = error;
975
+ }
976
+ assertCompositionFailure(
977
+ failure,
978
+ 'plugin-group-provider-missing',
979
+ 'missing-service',
980
+ 'inject/provide dependency',
981
+ 'provide the service',
982
+ );
983
+ expect(applied).toBe(0);
984
+ });
985
+
986
+ it('rejects a dependency cycle before activating either child', async () => {
987
+ let applied = 0;
988
+ const first: Plugin = {
989
+ name: 'first',
990
+ inject: ['second'],
991
+ provide: 'first',
992
+ apply: () => {
993
+ applied += 1;
994
+ },
995
+ };
996
+ const second: Plugin = {
997
+ name: 'second',
998
+ inject: ['first'],
999
+ provide: 'second',
1000
+ apply: () => {
1001
+ applied += 1;
1002
+ },
1003
+ };
1004
+ const group = definePluginGroup({
1005
+ name: 'cycle-group',
1006
+ children: () => [usePlugin(first), usePlugin(second)],
1007
+ });
1008
+
1009
+ let failure: unknown;
1010
+ try {
1011
+ await new Context().plugin(group);
1012
+ } catch (error: unknown) {
1013
+ failure = error;
1014
+ }
1015
+ assertCompositionFailure(
1016
+ failure,
1017
+ 'plugin-group-dependency-cycle',
1018
+ 'first',
1019
+ 'inject/provide dependency graph',
1020
+ 'break the dependency cycle',
1021
+ );
1022
+ expect(applied).toBe(0);
1023
+ });
1024
+
1025
+ it('preserves child identity across reorder and diffs add/remove children', async () => {
1026
+ const events: string[] = [];
1027
+ const first: Plugin = {
1028
+ name: 'first',
1029
+ apply(ctx) {
1030
+ events.push('first:apply');
1031
+ ctx.effect(() => () => events.push('first:dispose'));
1032
+ },
1033
+ };
1034
+ const second: Plugin = {
1035
+ name: 'second',
1036
+ apply(ctx) {
1037
+ events.push('second:apply');
1038
+ ctx.effect(() => () => events.push('second:dispose'));
1039
+ },
1040
+ };
1041
+ const third: Plugin = {
1042
+ name: 'third',
1043
+ apply(ctx) {
1044
+ events.push('third:apply');
1045
+ ctx.effect(() => () => events.push('third:dispose'));
1046
+ },
1047
+ };
1048
+ let children = [usePlugin(first), usePlugin(second)];
1049
+ const group = definePluginGroup({ name: 'diff-group', children: () => children });
1050
+ const ctx = new Context();
1051
+ const fiber = await ctx.plugin(group);
1052
+ expect(events).toEqual(['first:apply', 'second:apply']);
1053
+
1054
+ children = [usePlugin(second), usePlugin(first)];
1055
+ await fiber.update({});
1056
+ expect(events).toEqual(['first:apply', 'second:apply']);
1057
+
1058
+ children = [usePlugin(second), usePlugin(third)];
1059
+ await fiber.update({});
1060
+ expect(events).toEqual(['first:apply', 'second:apply', 'third:apply', 'first:dispose']);
1061
+ await ctx.fiber.dispose();
1062
+ expect(events).toEqual([
1063
+ 'first:apply',
1064
+ 'second:apply',
1065
+ 'third:apply',
1066
+ 'first:dispose',
1067
+ 'third:dispose',
1068
+ 'second:dispose',
1069
+ ]);
1070
+ });
1071
+
1072
+ it('preserves keyed identity across reorder of equivalent fresh data configs', async () => {
1073
+ const events: string[] = [];
1074
+ const childFibers = new Map<number, unknown[]>();
1075
+ const configurable: Plugin.Object<{ readonly value: number }> = {
1076
+ name: 'reorder-configurable',
1077
+ apply(ctx, config) {
1078
+ const fibers = childFibers.get(config.value) ?? [];
1079
+ fibers.push(ctx.fiber);
1080
+ childFibers.set(config.value, fibers);
1081
+ events.push(`apply:${config.value}`);
1082
+ ctx.effect(() => () => events.push(`dispose:${config.value}`));
1083
+ },
1084
+ };
1085
+ let children = [
1086
+ usePlugin(configurable, { value: 1 }, { key: 'first' }),
1087
+ usePlugin(configurable, { value: 2 }, { key: 'second' }),
1088
+ ];
1089
+ const group = definePluginGroup({ name: 'reorder-config-group', children: () => children });
1090
+ const ctx = new Context();
1091
+ const fiber = await ctx.plugin(group);
1092
+
1093
+ children = [
1094
+ usePlugin(configurable, { value: 2 }, { key: 'second' }),
1095
+ usePlugin(configurable, { value: 1 }, { key: 'first' }),
1096
+ ];
1097
+ await fiber.update({});
1098
+ expect(events).toEqual(['apply:1', 'apply:2']);
1099
+ expect(childFibers.get(1)).toHaveLength(1);
1100
+ expect(childFibers.get(2)).toHaveLength(1);
1101
+ await ctx.fiber.dispose();
1102
+ expect(events).toEqual(['apply:1', 'apply:2', 'dispose:2', 'dispose:1']);
1103
+ });
1104
+
1105
+ it('keeps the last-known-good child set after async failure and retries it', async () => {
1106
+ const events: string[] = [];
1107
+ let fail = true;
1108
+ const healthy: Plugin = {
1109
+ name: 'healthy-lkg',
1110
+ apply(ctx) {
1111
+ events.push('healthy:apply');
1112
+ ctx.effect(() => () => events.push('healthy:dispose'));
1113
+ },
1114
+ };
1115
+ const flaky: Plugin = {
1116
+ name: 'flaky',
1117
+ async apply(ctx) {
1118
+ events.push('flaky:apply');
1119
+ if (fail) throw new Error('temporary child failure');
1120
+ ctx.effect(() => () => events.push('flaky:dispose'));
1121
+ },
1122
+ };
1123
+ let children = [usePlugin(healthy)];
1124
+ const group = definePluginGroup({ name: 'lkg-group', children: () => children });
1125
+ const ctx = new Context();
1126
+ const fiber = await ctx.plugin(group);
1127
+ expect(events).toEqual(['healthy:apply']);
1128
+
1129
+ children = [usePlugin(healthy), usePlugin(flaky)];
1130
+ let failure: unknown;
1131
+ try {
1132
+ await fiber.update({});
1133
+ } catch (error: unknown) {
1134
+ failure = error;
1135
+ }
1136
+ assertCompositionFailure(
1137
+ failure,
1138
+ 'plugin-group-child-failed',
1139
+ 'flaky',
1140
+ 'owning Group',
1141
+ 'repair the child',
1142
+ );
1143
+ expect(events).toEqual(['healthy:apply', 'flaky:apply']);
1144
+
1145
+ fail = false;
1146
+ await fiber.update({});
1147
+ expect(events).toEqual(['healthy:apply', 'flaky:apply', 'flaky:apply']);
1148
+ await ctx.fiber.dispose();
1149
+ expect(events).toEqual([
1150
+ 'healthy:apply',
1151
+ 'flaky:apply',
1152
+ 'flaky:apply',
1153
+ 'flaky:dispose',
1154
+ 'healthy:dispose',
1155
+ ]);
1156
+ });
1157
+
1158
+ it('reports retirement disposal failure through the Group error contract and preserves LKG', async () => {
1159
+ let failDispose = true;
1160
+ const events: string[] = [];
1161
+ const retiringProvider: Plugin = {
1162
+ name: 'retiring-provider',
1163
+ provide: 'answer',
1164
+ apply(ctx) {
1165
+ ctx.provide('answer', 1);
1166
+ const fiber = ctx.fiber as unknown as { dispose: () => Promise<void> };
1167
+ const originalDispose = fiber.dispose.bind(ctx.fiber);
1168
+ fiber.dispose = async () => {
1169
+ events.push('retiring:dispose');
1170
+ await originalDispose();
1171
+ if (failDispose) throw new Error('retirement failed');
1172
+ };
1173
+ },
1174
+ };
1175
+ const consumer: Plugin = {
1176
+ name: 'retirement-consumer',
1177
+ inject: ['answer'],
1178
+ apply(ctx) {
1179
+ events.push(`consumer:${ctx.get('answer')}`);
1180
+ },
1181
+ };
1182
+ let children = [usePlugin(retiringProvider), usePlugin(consumer)];
1183
+ const group = definePluginGroup({
1184
+ name: 'retirement-failure-group',
1185
+ children: () => children,
1186
+ });
1187
+ const ctx = new Context();
1188
+ const fiber = await ctx.plugin(group);
1189
+
1190
+ children = [];
1191
+ let failure: unknown;
1192
+ try {
1193
+ await fiber.update({});
1194
+ } catch (error: unknown) {
1195
+ failure = error;
1196
+ }
1197
+ assertCompositionFailure(
1198
+ failure,
1199
+ 'plugin-group-child-failed',
1200
+ 'retiring-provider',
1201
+ 'owning Group',
1202
+ 'repair the child',
1203
+ );
1204
+ expect(ctx.get('answer')).toBe(1);
1205
+ expect(events).toEqual(['consumer:1', 'retiring:dispose', 'consumer:1']);
1206
+
1207
+ failDispose = false;
1208
+ await fiber.update({});
1209
+ expect(ctx.get('answer')).toBeUndefined();
1210
+ await ctx.fiber.dispose();
1211
+ });
1212
+
1213
+ it('updates an existing child config without changing its identity', async () => {
1214
+ const events: string[] = [];
1215
+ const childFibers: unknown[] = [];
1216
+ const configurable: Plugin.Object<{ readonly value: number }> = {
1217
+ name: 'configurable',
1218
+ apply(ctx, config) {
1219
+ childFibers.push(ctx.fiber);
1220
+ events.push(`apply:${config.value}`);
1221
+ ctx.effect(() => () => events.push(`dispose:${config.value}`));
1222
+ },
1223
+ };
1224
+ let children = [usePlugin(configurable, { value: 1 })];
1225
+ const group = definePluginGroup({ name: 'config-update-group', children: () => children });
1226
+ const ctx = new Context();
1227
+ const fiber = await ctx.plugin(group);
1228
+ expect(events).toEqual(['apply:1']);
1229
+
1230
+ children = [usePlugin(configurable, { value: 2 })];
1231
+ await fiber.update({});
1232
+ expect(events).toEqual(['apply:1', 'dispose:1', 'apply:2']);
1233
+ expect(childFibers).toHaveLength(2);
1234
+ expect(childFibers[0]).toBe(childFibers[1]);
1235
+ await ctx.fiber.dispose();
1236
+ expect(events).toEqual(['apply:1', 'dispose:1', 'apply:2', 'dispose:2']);
1237
+ });
1238
+
1239
+ it('activates keyed instances independently and disposes only the removed key', async () => {
1240
+ const events: string[] = [];
1241
+ const childFibers: unknown[] = [];
1242
+ const reusable: Plugin.Object<{ readonly value: number }> = {
1243
+ name: 'reusable',
1244
+ apply(ctx, config) {
1245
+ childFibers.push(ctx.fiber);
1246
+ events.push(`apply:${config.value}`);
1247
+ ctx.effect(() => () => events.push(`dispose:${config.value}`));
1248
+ },
1249
+ };
1250
+ const leftConfig = { value: 1 };
1251
+ const rightConfig = { value: 2 };
1252
+ let children = [
1253
+ usePlugin(reusable, leftConfig, { key: 'left' }),
1254
+ usePlugin(reusable, rightConfig, { key: 'right' }),
1255
+ ];
1256
+ const group = definePluginGroup({ name: 'keyed-group', children: () => children });
1257
+ const ctx = new Context();
1258
+ const fiber = await ctx.plugin(group);
1259
+ expect(events).toEqual(['apply:1', 'apply:2']);
1260
+ expect(childFibers).toHaveLength(2);
1261
+ expect(childFibers[0]).not.toBe(childFibers[1]);
1262
+
1263
+ children = [usePlugin(reusable, rightConfig, { key: 'right' })];
1264
+ await fiber.update({});
1265
+ expect(events).toEqual(['apply:1', 'apply:2', 'dispose:1']);
1266
+ expect(childFibers).toHaveLength(2);
1267
+
1268
+ await ctx.fiber.dispose();
1269
+ expect(events).toEqual(['apply:1', 'apply:2', 'dispose:1', 'dispose:2']);
1270
+ });
1271
+ });
1272
+
1273
+ describe('project plugin owner validation', () => {
1274
+ it('returns a structured failure for mixed realm ownership', () => {
1275
+ const entries = [
1276
+ {
1277
+ id: 'mixed',
1278
+ name: 'cordis:group',
1279
+ group: true,
1280
+ realm: 'engine' as const,
1281
+ config: [{ id: 'host', name: '@game/host', realm: 'host' as const }],
1282
+ },
1283
+ ];
1284
+
1285
+ expect(() => projectPluginEntries(entries, 'engine')).toThrow(CatalogLoaderError);
1286
+ try {
1287
+ projectPluginEntries(entries, 'engine');
1288
+ } catch (error: unknown) {
1289
+ assertCatalogFailure(
1290
+ error,
1291
+ 'plugin-entry-realm-mixed',
1292
+ 'mixed',
1293
+ 'engine physical realm',
1294
+ 'Split Host and Engine',
1295
+ );
1296
+ }
1297
+ });
1298
+
1299
+ it('keeps missing module, realm mismatch, and unsupported realm failures closed', async () => {
1300
+ const ctx = new Context();
1301
+ const { loader } = await installCatalogLoader(
1302
+ ctx,
1303
+ new Map([
1304
+ [
1305
+ '@game/host',
1306
+ { realm: 'host' as const, load: async () => ({ default: () => undefined }) },
1307
+ ],
1308
+ ]),
1309
+ 'engine',
1310
+ );
1311
+
1312
+ for (const candidate of [
1313
+ { name: '@game/missing', code: 'plugin-catalog-missing' as const },
1314
+ { name: '@game/host', code: 'plugin-realm-mismatch' as const },
1315
+ ]) {
1316
+ let failure: unknown;
1317
+ try {
1318
+ await loader.create({ name: candidate.name });
1319
+ } catch (error: unknown) {
1320
+ failure = error instanceof Error && 'cause' in error ? error.cause : error;
1321
+ }
1322
+ assertCatalogFailure(
1323
+ failure,
1324
+ candidate.code,
1325
+ candidate.name,
1326
+ candidate.code === 'plugin-catalog-missing' ? 'generated catalog' : 'engine realm',
1327
+ candidate.code === 'plugin-catalog-missing' ? 'Install the package' : 'realm-specific',
1328
+ );
1329
+ }
1330
+
1331
+ const unsupported = await bootstrapCatalogLoader(ctx, new Map(), 'host', {
1332
+ catalogDigest: 'digest',
1333
+ supportedRealms: ['engine'],
1334
+ });
1335
+ expect(unsupported.ok).toBe(false);
1336
+ if (!unsupported.ok) {
1337
+ assertCatalogFailure(
1338
+ unsupported.error,
1339
+ 'plugin-realm-unsupported',
1340
+ 'host',
1341
+ 'supported by this host',
1342
+ 'capability matrix',
1343
+ );
1344
+ }
1345
+ await ctx.fiber.dispose();
1346
+ });
1347
+ });