blob: 0634530c9feb69539edbaaf387d42d149a611bb6 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import assert from 'node:assert/strict';
import test from 'node:test';
import { Context, FiberState, Service, type Plugin } from '../plugin-kernel.js';
import { registerPluginContribution } from '../plugin-runtime.js';
declare module '../plugin-kernel.js' {
interface Context {
fixture?: { readonly value: string };
}
}
test('injected plugins wait for Services and reload when ownership changes', async () => {
const root = new Context();
const lifecycle: string[] = [];
const consumer = Object.assign(
(ctx: Context) => {
const value = ctx.fixture?.value;
lifecycle.push(`load:${value}`);
return () => lifecycle.push(`dispose:${value}`);
},
{ inject: ['fixture'] },
) satisfies Plugin;
const fiber = root.plugin(consumer);
await fiber.await();
assert.equal(fiber.state, FiberState.PENDING);
const removeFirst = root.provide('fixture', { value: 'first' });
await fiber.await();
assert.equal(fiber.state, FiberState.ACTIVE);
assert.deepEqual(lifecycle, ['load:first']);
await removeFirst();
await fiber.await();
assert.equal(fiber.state, FiberState.PENDING);
assert.deepEqual(lifecycle, ['load:first', 'dispose:first']);
root.provide('fixture', { value: 'second' });
await fiber.await();
assert.equal(fiber.state, FiberState.ACTIVE);
assert.deepEqual(lifecycle, ['load:first', 'dispose:first', 'load:second']);
await root.fiber.dispose();
});
test('Services provided by a plugin activate dependent plugins after the provider is active', async () => {
const root = new Context();
let consumed: string | undefined;
const consumer = Object.assign(
(ctx: Context) => {
consumed = ctx.get<{ readonly value: string }>('pluginService')?.value;
},
{ inject: ['pluginService'] },
);
const consumerFiber = root.plugin(consumer);
await consumerFiber.await();
assert.equal(consumerFiber.state, FiberState.PENDING);
const providerFiber = root.plugin((ctx) => {
ctx.provide('pluginService', { value: 'ready' });
});
await providerFiber.await();
await consumerFiber.await();
assert.equal(providerFiber.state, FiberState.ACTIVE);
assert.equal(consumerFiber.state, FiberState.ACTIVE);
assert.equal(consumed, 'ready');
await root.fiber.dispose();
});
test('Service health-check failures move consumers to failed and allow recovery', async () => {
const root = new Context();
let healthy = true;
let activations = 0;
root.provide('checkedService', { value: 1 }, () => {
if (!healthy) throw new Error('Service health check failed');
return true;
});
const consumer = root.plugin(
Object.assign(
() => {
activations += 1;
},
{ inject: ['checkedService'] },
),
);
await consumer.await();
assert.equal(consumer.state, FiberState.ACTIVE);
healthy = false;
root.set('checkedService', { value: 2 });
await assert.rejects(consumer.await(), /Service health check failed/u);
assert.equal(consumer.state, FiberState.FAILED);
healthy = true;
root.set('checkedService', { value: 3 });
await consumer.await();
assert.equal(consumer.state, FiberState.ACTIVE);
assert.equal(activations, 2);
await root.fiber.dispose();
});
test('a provider with multiple Services activates each dependent Fiber once', async () => {
const root = new Context();
let activations = 0;
const consumer = root.plugin(
Object.assign(
() => {
activations += 1;
},
{ inject: ['firstService', 'secondService'] },
),
);
await consumer.await();
const provider = root.plugin((ctx) => {
ctx.provide('firstService', { value: 1 });
ctx.provide('secondService', { value: 2 });
});
await provider.await();
await consumer.await();
assert.equal(provider.state, FiberState.ACTIVE);
assert.equal(consumer.state, FiberState.ACTIVE);
assert.equal(activations, 1);
await root.fiber.dispose();
});
test('rapid Service updates coalesce dependent Fiber reloads around the latest value', async () => {
const root = new Context();
root.provide('fixture', { value: 1 });
const activations: number[] = [];
const consumer = root.plugin(
Object.assign(
(ctx: Context) => {
activations.push(ctx.get<{ readonly value: number }>('fixture')!.value);
},
{ inject: ['fixture'] },
),
);
await consumer.await();
root.set('fixture', { value: 2 });
root.set('fixture', { value: 3 });
await consumer.await();
assert.deepEqual(activations, [1, 3]);
await root.fiber.dispose();
});
test('Fiber await includes a Service refresh queued during activation', async () => {
const root = new Context();
root.provide('fixture', { value: 1 });
const activations: number[] = [];
let releaseFirst!: () => void;
let releaseSecond!: () => void;
let signalFirst!: () => void;
let signalSecond!: () => void;
const firstGate = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const secondGate = new Promise<void>((resolve) => {
releaseSecond = resolve;
});
const firstStarted = new Promise<void>((resolve) => {
signalFirst = resolve;
});
const secondStarted = new Promise<void>((resolve) => {
signalSecond = resolve;
});
const consumer = root.plugin(
Object.assign(
async (ctx: Context) => {
const value = ctx.get<{ readonly value: number }>('fixture')!.value;
activations.push(value);
if (value === 1) {
signalFirst();
await firstGate;
} else {
signalSecond();
await secondGate;
}
},
{ inject: ['fixture'] },
),
);
await firstStarted;
const settled = consumer.await();
root.set('fixture', { value: 2 });
releaseFirst();
await secondStarted;
let completed = false;
void settled.then(() => {
completed = true;
});
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(completed, false);
releaseSecond();
await settled;
assert.equal(consumer.state, FiberState.ACTIVE);
assert.deepEqual(activations, [1, 2]);
await root.fiber.dispose();
});
test('Fiber await observes the final state after an intermediate transition rejects', async () => {
const root = new Context();
const fiber = root.plugin((_ctx, config: string) => {
if (config === 'broken') throw new Error('broken transition');
}, 'initial');
await fiber.await();
const broken = fiber.update('broken');
const recovered = fiber.update('recovered');
await fiber.await();
await assert.rejects(broken, /broken transition/u);
await recovered;
assert.equal(fiber.state, FiberState.ACTIVE);
assert.equal(fiber.config, 'recovered');
await root.fiber.dispose();
});
test('fire-and-forget Fiber activation failures do not become unhandled rejections', async () => {
const root = new Context();
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => unhandled.push(reason);
process.on('unhandledRejection', onUnhandled);
try {
const fiber = root.plugin(() => {
throw new Error('activation failed');
});
await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(unhandled, []);
await assert.rejects(fiber.await(), /activation failed/u);
} finally {
process.off('unhandledRejection', onUnhandled);
await root.fiber.dispose().catch(() => undefined);
}
});
test('asynchronous Effect setup and cleanup failures are observed internally', async () => {
const root = new Context();
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => unhandled.push(reason);
process.on('unhandledRejection', onUnhandled);
try {
const fiber = root.plugin((ctx) => {
ctx.effect(async function* () {
yield () => {
throw new Error('effect cleanup failed');
};
throw new Error('effect setup failed');
}, 'failed-async-effect');
});
await fiber.await();
await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(unhandled, []);
assert.ok(fiber.error instanceof AggregateError);
} finally {
process.off('unhandledRejection', onUnhandled);
await root.fiber.dispose().catch(() => undefined);
}
});
test('synchronous Effect setup failures preserve the active Fiber contract', async () => {
const root = new Context();
const fiber = root.plugin((ctx) => {
ctx.effect(() => {
throw new Error('synchronous effect setup failed');
}, 'failed-sync-effect');
});
await fiber.await();
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(fiber.state, FiberState.ACTIVE);
assert.match(String(fiber.error), /synchronous effect setup failed/u);
await root.fiber.dispose();
});
test('Service isolation keeps sibling implementations independent', async () => {
const root = new Context();
root.provide('fixture', { value: 'root' });
const isolated = root.isolate('fixture');
isolated.provide('fixture', { value: 'isolated' });
assert.equal(root.get<{ value: string }>('fixture')?.value, 'root');
assert.equal(isolated.get<{ value: string }>('fixture')?.value, 'isolated');
assert.equal(root.extend().get<{ value: string }>('fixture')?.value, 'root');
await root.fiber.dispose();
});
test('Fiber update preserves identity and disposes Effects in reverse order', async () => {
const root = new Context();
const lifecycle: string[] = [];
const plugin = (ctx: Context, config: { value: number }) => {
lifecycle.push(`load:${config.value}`);
ctx.effect(() => () => lifecycle.push(`first:${config.value}`), 'first');
ctx.effect(() => () => lifecycle.push(`second:${config.value}`), 'second');
};
const fiber = root.plugin(plugin, { value: 1 });
await fiber.await();
const id = fiber.id;
await fiber.update({ value: 2 });
assert.equal(fiber.id, id);
assert.deepEqual(lifecycle, ['load:1', 'second:1', 'first:1', 'load:2']);
assert.deepEqual(
fiber.getEffects().map(({ label }) => label),
['first', 'second'],
);
await root.fiber.dispose();
});
test('Fiber Proxy exposes getters backed by private state', async () => {
const root = new Context();
const fiber = root.plugin({ name: 'named-plugin', apply: () => undefined });
assert.equal(fiber.name, 'named-plugin');
await fiber.await();
await root.fiber.dispose();
});
test('Plugin objects sharing apply retain independent Runtime metadata', async () => {
const root = new Context();
const activations: string[] = [];
const apply = (_ctx: Context, config: string) => {
activations.push(config);
};
const plugin = (name: string, prefix: string): Plugin.Object => ({
name,
apply,
Config: {
'~standard': {
validate: (value) => ({ value: `${prefix}:${String(value)}` }),
},
},
});
const first = root.plugin(plugin('first-plugin', 'first'), 'a');
const second = root.plugin(plugin('second-plugin', 'second'), 'b');
await Promise.all([first.await(), second.await()]);
assert.equal(first.name, 'first-plugin');
assert.equal(second.name, 'second-plugin');
assert.deepEqual(activations, ['first:a', 'second:b']);
await root.fiber.dispose();
});
test('Standard Schema validation preserves raw Fiber config across restarts', async () => {
const root = new Context();
const values: string[] = [];
const plugin: Plugin.Object = {
Config: {
'~standard': {
validate: (value) => ({ value: `parsed:${String(value)}` }),
},
},
apply(_ctx, config) {
values.push(String(config));
},
};
const fiber = root.plugin(plugin, 'raw');
await fiber.await();
await fiber.restart();
assert.equal(fiber.config, 'raw');
assert.deepEqual(values, ['parsed:raw', 'parsed:raw']);
await root.fiber.dispose();
});
test('Fiber update restores the previous active config after activation fails', async () => {
const root = new Context();
const lifecycle: string[] = [];
const fiber = root.plugin(
(_ctx, config: { value: number }) => {
lifecycle.push(`load:${config.value}`);
if (config.value === 2) throw new Error('invalid update');
return () => lifecycle.push(`dispose:${config.value}`);
},
{ value: 1 },
);
await fiber.await();
await assert.rejects(fiber.update({ value: 2 }), /invalid update/u);
assert.equal(fiber.state, FiberState.ACTIVE);
assert.deepEqual(fiber.config, { value: 1 });
assert.deepEqual(lifecycle, ['load:1', 'dispose:1', 'load:2', 'load:1']);
await root.fiber.dispose();
});
test('concurrent Fiber updates serialize config activation and isolate rollback', async () => {
const values: string[] = [];
const root = new Context();
const fiber = root.plugin((_ctx, config: string) => {
values.push(config);
if (config === 'broken') throw new Error('broken config');
}, 'initial');
await fiber.await();
await Promise.all([fiber.update('first'), fiber.update('second')]);
assert.deepEqual(values, ['initial', 'first', 'second']);
assert.equal(fiber.config, 'second');
const results = await Promise.allSettled([fiber.update('broken'), fiber.update('final')]);
assert.equal(results[0]?.status, 'rejected');
assert.equal(results[1]?.status, 'fulfilled');
assert.deepEqual(values, ['initial', 'first', 'second', 'broken', 'second', 'final']);
assert.equal(fiber.config, 'final');
assert.equal(fiber.state, FiberState.ACTIVE);
await fiber.dispose();
await root.fiber.dispose();
});
test('Fiber cleanup exhausts Effects before reporting disposer failures', async () => {
const root = new Context();
const lifecycle: string[] = [];
const fiber = root.plugin((ctx) => {
ctx.effect(() => () => lifecycle.push('first'));
ctx.effect(() => () => {
lifecycle.push('failing');
throw new Error('cleanup failed');
});
ctx.effect(() => () => lifecycle.push('last'));
});
await fiber.await();
const firstDispose = fiber.dispose();
const concurrentDispose = fiber.dispose();
assert.equal(concurrentDispose, firstDispose);
const firstError = await firstDispose.then(
() => undefined,
(error: unknown) => error,
);
assert.ok(firstError instanceof AggregateError);
const retryDispose = fiber.dispose();
assert.equal(retryDispose, firstDispose);
const retryError = await retryDispose.then(
() => undefined,
(error: unknown) => error,
);
assert.equal(retryError, firstError);
assert.deepEqual(lifecycle, ['last', 'failing', 'first']);
assert.equal(fiber.state, FiberState.DISPOSED);
assert.equal(root.kernelFibers().includes(fiber), false);
await root.fiber.dispose();
});
test('Service loss preserves consumer cleanup failure on the owning Fiber', async () => {
const root = new Context();
const removeService = root.provide('fixture', { value: 'ready' });
let activations = 0;
const consumer = root.plugin(
Object.assign(
() => {
activations += 1;
return () => {
throw new Error('cleanup boom');
};
},
{ inject: ['fixture'] },
),
);
await consumer.await();
assert.equal(consumer.state, FiberState.ACTIVE);
await removeService();
assert.equal(consumer.state, FiberState.FAILED);
const error = await consumer.await().then(
() => undefined,
(reason: unknown) => reason,
);
assert.ok(error instanceof AggregateError);
const fiberCleanup = error.errors[0];
assert.ok(fiberCleanup instanceof AggregateError);
const effectCleanup = fiberCleanup.errors[0];
assert.ok(effectCleanup instanceof AggregateError);
assert.match(String(effectCleanup.errors[0]), /cleanup boom/u);
root.provide('fixture', { value: 'replacement' });
await assert.rejects(consumer.await(), AggregateError);
assert.equal(consumer.state, FiberState.FAILED);
assert.equal(activations, 1);
await root.fiber.dispose();
});
test('Fiber owns derived Context views, child Fibers, and their Effects', async () => {
const root = new Context();
const lifecycle: string[] = [];
let accountA!: Context;
let accountB!: Context;
let child!: ReturnType<Context['plugin']>;
const owner = root.plugin((ctx) => {
accountA = ctx.extend({ account: 'a' });
accountB = ctx.extend({ account: 'b' });
accountA.effect(() => () => lifecycle.push('account-a'), 'account-a');
accountB.effect(() => () => lifecycle.push('account-b'), 'account-b');
child = accountB.plugin(() => undefined);
});
await owner.await();
await child.await();
assert.equal(owner.context.fiber, owner);
assert.equal(accountA.fiber, owner);
assert.equal(accountB.fiber, owner);
assert.equal(child.parent, owner);
assert.throws(
() => child.deriveContext(accountA),
/Cannot derive a Context view from another Fiber/u,
);
assert.throws(
() => child.mount(accountB, () => undefined),
/Cannot mount a child through a Context owned by another Fiber/u,
);
assert.deepEqual(
owner.getEffects().map(({ label }) => label),
['account-a', 'account-b'],
);
await owner.dispose();
assert.equal(child.state, FiberState.DISPOSED);
assert.deepEqual(lifecycle, ['account-b', 'account-a']);
await root.fiber.dispose();
});
test('concurrent Effect disposal shares the same completion task', async () => {
const root = new Context();
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const dispose = root.effect(() => async () => gate, 'slow-cleanup');
const first = dispose();
const second = dispose();
assert.equal(second, first);
let completed = false;
void second.then(() => {
completed = true;
});
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(completed, false);
release();
await first;
assert.equal(completed, true);
await root.fiber.dispose();
});
test('Plugin Runtime metadata cache uses weak Plugin identities', async () => {
const root = new Context();
assert.ok(root._kernel().runtimes instanceof WeakMap);
await root.fiber.dispose();
});
test('accessors cannot shadow existing Context properties', async () => {
const root = new Context();
assert.throws(
() => root.accessor('plugin', { get: () => 'hidden' }),
/Context property already exists: plugin/u,
);
await root.fiber.dispose();
});
test('child accessors cannot shadow metadata inherited from parent Contexts', async () => {
const root = new Context();
const parent = root.extend({ inheritedMeta: 'visible' });
const child = parent.extend();
assert.throws(
() => child.accessor('inheritedMeta', { get: () => 'hidden' }),
/Context property already exists: inheritedMeta/u,
);
assert.equal(Reflect.get(child, 'inheritedMeta'), 'visible');
await root.fiber.dispose();
});
test('Context metadata cannot replace Fiber ownership or environment topology', async () => {
const root = new Context();
for (const field of ['fiber', 'parent', 'root', 'logger']) {
assert.throws(
() => root.extend({ [field]: undefined }),
new RegExp(`Context metadata cannot overwrite owned field: ${field}`, 'u'),
);
}
const child = root.extend({ account: 'fixture' });
assert.equal(child.fiber, root.fiber);
assert.equal(child.parent, root);
assert.equal(child.root, root);
await root.fiber.dispose();
});
test('Services cannot shadow metadata inherited from parent Contexts', async () => {
const root = new Context();
root.provide('entryMetadata', { value: 'service' });
const metadata = { value: 'metadata' };
const child = root.extend({ entryMetadata: metadata }).extend();
assert.equal(Reflect.get(child, 'entryMetadata'), metadata);
assert.deepEqual(child.get('entryMetadata'), { value: 'service' });
await root.fiber.dispose();
});
test('mixin validates every target before publishing accessors', async () => {
const root = new Context();
assert.throws(
() => root.mixin({ first: 1, second: 2 }, { first: 'mixed', second: 'plugin' }),
/Context property already exists: plugin/u,
);
assert.equal(root._kernel().accessors.has('mixed'), false);
assert.equal(Reflect.get(root, 'mixed'), undefined);
await root.fiber.dispose();
});
test('unknown Context property reads do not create Service labels', async () => {
const root = new Context();
const labels = root._kernel().serviceLabels;
for (let index = 0; index < 100; index += 1) {
assert.equal(Reflect.get(root, `unknownService${index}`), undefined);
}
assert.equal(labels.size, 0);
const service = { value: 'available' };
root.provide('futureService', service);
assert.equal(Reflect.get(root, 'futureService'), service);
assert.equal(labels.size, 1);
await root.fiber.dispose();
});
test('event dispatch supports emit, parallel, serial, bail, and waterfall', async () => {
const root = new Context();
const emitted: string[] = [];
root.on('emit', (value) => emitted.push(`one:${String(value)}`));
root.on('emit', (value) => emitted.push(`two:${String(value)}`));
root.emit('emit', 1);
assert.deepEqual(emitted, ['one:1', 'two:1']);
const parallel: string[] = [];
root.on('parallel', async () => {
await Promise.resolve();
parallel.push('one');
});
root.on('parallel', () => parallel.push('two'));
await root.parallel('parallel');
assert.deepEqual(parallel.sort(), ['one', 'two']);
const attempted: string[] = [];
root.on('parallel-error', () => {
attempted.push('throwing');
throw new Error('synchronous listener failed');
});
root.on('parallel-error', () => attempted.push('following'));
await assert.rejects(root.parallel('parallel-error'), AggregateError);
assert.deepEqual(attempted, ['throwing', 'following']);
root.on('serial', () => undefined);
root.on('serial', () => 'stop');
root.on('serial', () => 'unreachable');
assert.equal(await root.serial('serial'), 'stop');
root.on('bail', () => false);
root.on('bail', () => 42);
assert.equal(root.bail('bail'), 42);
root.on('waterfall', (value, next) => `outer(${String((next as () => unknown)())}:${value})`);
root.on('waterfall', (value, next) => `inner(${String((next as () => unknown)())}:${value})`);
assert.equal(
root.waterfall('waterfall', 'x', () => 'base'),
'outer(inner(base:x):x)',
);
await root.fiber.dispose();
});
test('event hooks are not published while their Fiber is unloading', async () => {
const root = new Context();
let calls = 0;
const fiber = root.plugin((ctx) => () => {
ctx.on('late-hook', () => {
calls += 1;
});
});
await fiber.await();
await assert.rejects(fiber.dispose(), AggregateError);
root.emit('late-hook');
assert.equal(calls, 0);
await root.fiber.dispose();
});
test('unloading Contexts cannot create escaping child Fibers', async () => {
const root = new Context();
let childActivations = 0;
const fiber = root.plugin((ctx) => () => {
ctx.plugin(() => {
childActivations += 1;
});
});
await fiber.await();
await assert.rejects(fiber.dispose(), AggregateError);
assert.equal(childActivations, 0);
assert.deepEqual(
root.kernelFibers().map(({ id }) => id),
[0],
);
await root.fiber.dispose();
});
test('dispose requests immediately fence child Fiber creation and Service labels', async () => {
const root = new Context();
let context!: Context;
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const fiber = root.plugin(async (ctx, config: string) => {
context = ctx;
if (config === 'slow') await gate;
}, 'ready');
await fiber.await();
const update = fiber.update('slow');
await new Promise<void>((resolve) => setImmediate(resolve));
const dispose = fiber.dispose();
assert.throws(() => context.plugin(() => undefined), /Plugin Context is disposed/u);
assert.throws(
() => context.provide('lateService', { value: true }),
/Plugin Context is disposed/u,
);
assert.equal(root._kernel().serviceLabels.has('lateService'), false);
release();
await update;
await dispose;
await root.fiber.dispose();
});
test('late contribution registration is rejected before acquiring resources', async () => {
const root = new Context();
let registrations = 0;
const fiber = root.plugin((ctx) => () => {
registerPluginContribution(ctx, 'late-contribution', () => {
registrations += 1;
return () => undefined;
});
});
await fiber.await();
await assert.rejects(fiber.dispose(), AggregateError);
assert.equal(registrations, 0);
await root.fiber.dispose();
});
test('intercept configuration is inherited without mutating parent Contexts', async () => {
const root = new Context();
const child = root.intercept('fixture', { child: true });
const grandchild = child.intercept('fixture', { grandchild: true });
assert.deepEqual(root.interceptConfig('fixture'), []);
assert.deepEqual(child.interceptConfig('fixture'), [{ child: true }]);
assert.deepEqual(grandchild.interceptConfig('fixture'), [{ child: true }, { grandchild: true }]);
await root.fiber.dispose();
});
test('Service records support names inherited from Object.prototype', async () => {
class FixtureService extends Service<Record<string, unknown>> {
merge(): Record<string, unknown> {
return this.resolveConfig({ base: true });
}
}
const root = new Context();
const intercepted = root.intercept('constructor', { intercepted: true });
const isolated = intercepted.isolate('constructor');
const service = new FixtureService(isolated, 'constructor');
const bound = isolated.get<FixtureService>('constructor');
assert.deepEqual(intercepted.interceptConfig('constructor'), [{ intercepted: true }]);
assert.ok(bound);
assert.deepEqual(bound.merge(), { base: true, intercepted: true });
assert.deepEqual(service.merge(), { base: true, intercepted: true });
await root.fiber.dispose();
});