Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 21 additions & 19 deletions packages/nest/test/open-feature.module.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,6 @@ describe('OpenFeatureModule', () => {
await moduleRef.close();
});

describe('without configured providers', () => {
let moduleWithoutProvidersRef: TestingModule;
beforeAll(async () => {
moduleWithoutProvidersRef = await Test.createTestingModule({
imports: [OpenFeatureModule.forRoot({})],
}).compile();
});

afterAll(async () => {
await moduleWithoutProvidersRef.close();
});

it('should return the SDKs default provider and not throw', async () => {
expect(() => {
moduleWithoutProvidersRef.get<Client>(getOpenFeatureClientToken());
}).not.toThrow();
});
});

it('should return the default provider', async () => {
const client = moduleRef.get<Client>(getOpenFeatureClientToken());
expect(client).toBeDefined();
Expand Down Expand Up @@ -91,6 +72,27 @@ describe('OpenFeatureModule', () => {
OpenFeature.clearHooks();
}
});

// Placed after provider-dependent tests: closing this inner module calls OpenFeature.close()
// which now fully resets API state per spec requirement 1.6.2.
describe('without configured providers', () => {
let moduleWithoutProvidersRef: TestingModule;
beforeAll(async () => {
moduleWithoutProvidersRef = await Test.createTestingModule({
imports: [OpenFeatureModule.forRoot({})],
}).compile();
});

afterAll(async () => {
await moduleWithoutProvidersRef.close();
});

it('should return the SDKs default provider and not throw', async () => {
expect(() => {
moduleWithoutProvidersRef.get<Client>(getOpenFeatureClientToken());
}).not.toThrow();
});
});
});

describe('handlers', () => {
Expand Down
7 changes: 7 additions & 0 deletions packages/server/src/open-feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,13 @@ export class OpenFeatureAPI
);
}

async close(): Promise<void> {
await super.close();
this._domainScopedProviders.clear();
this._defaultProvider = new ProviderWrapper(NOOP_PROVIDER, ProviderStatus.NOT_READY, this._statusEnumType);
this._transactionContextPropagator = NOOP_TRANSACTION_CONTEXT_PROPAGATOR;
}

/**
* Clears all registered providers and resets the default provider.
* @returns {Promise<void>}
Expand Down
102 changes: 100 additions & 2 deletions packages/server/test/open-feature.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Paradigm } from '@openfeature/core';
import type { Provider, ProviderStatus } from '../src';
import { OpenFeature, OpenFeatureAPI } from '../src';
import type { Hook, Provider, ProviderStatus, TransactionContextPropagator } from '../src';
import { NOOP_PROVIDER, OpenFeature, OpenFeatureAPI, ProviderEvents } from '../src';
import { OpenFeatureClient } from '../src/client/internal/open-feature-client';

const mockProvider = (config?: { initialStatus?: ProviderStatus; runsOn?: Paradigm }) => {
Expand Down Expand Up @@ -230,4 +230,102 @@ describe('OpenFeature', () => {
expect(provider3.onClose).toHaveBeenCalled();
});
});

describe('Requirement 1.6.2', () => {
it('resets the default provider to no-op after close()', async () => {
const provider = mockProvider();
OpenFeature.setProvider(provider);
expect(OpenFeature.providerMetadata.name).toBe('mock-events-success');
await OpenFeature.close();
expect(OpenFeature.providerMetadata.name).toBe(NOOP_PROVIDER.metadata.name);
});

it('clears domain-scoped providers after close()', async () => {
const provider = mockProvider();
OpenFeature.setProvider('domain1', provider);
expect(OpenFeature.getProvider('domain1')).toBe(provider);
await OpenFeature.close();
expect(OpenFeature.getProvider('domain1').metadata.name).toBe(NOOP_PROVIDER.metadata.name);
});

it('clears global hooks after close()', async () => {
const hook = { before: jest.fn() } as unknown as Hook;
OpenFeature.addHooks(hook);
expect(OpenFeature.getHooks()).toHaveLength(1);
await OpenFeature.close();
expect(OpenFeature.getHooks()).toHaveLength(0);
});

it('clears global evaluation context after close()', async () => {
OpenFeature.setContext({ user: 'test' });
expect(OpenFeature.getContext()).toEqual({ user: 'test' });
await OpenFeature.close();
expect(OpenFeature.getContext()).toEqual({});
});

it('removes API-level event handlers after close()', async () => {
const handler = jest.fn();
OpenFeature.addHandler(ProviderEvents.Ready, handler);
expect(OpenFeature.getHandlers(ProviderEvents.Ready)).toHaveLength(1);
await OpenFeature.close();
expect(OpenFeature.getHandlers(ProviderEvents.Ready)).toHaveLength(0);
});

it('resets the transaction context propagator to the default after close()', async () => {
const customPropagator: TransactionContextPropagator = {
getTransactionContext: jest.fn(() => ({ custom: true })),
setTransactionContext: jest.fn(),
};
OpenFeature.setTransactionContextPropagator(customPropagator);
expect(OpenFeature.getTransactionContext()).toEqual({ custom: true });
await OpenFeature.close();
expect(OpenFeature.getTransactionContext()).toEqual({});
});

it('calls provider onClose only once when the same provider instance is registered in multiple scopes', async () => {
const provider = mockProvider();
OpenFeature.setProvider(provider);
OpenFeature.setProvider('domain1', provider);
await OpenFeature.close();
expect(provider.onClose).toHaveBeenCalledTimes(1);
});
});

describe('clearProviders() remains provider-only', () => {
afterEach(async () => {
OpenFeature.clearHooks();
OpenFeature.clearHandlers();
OpenFeature.setContext({});
});

it('does not clear global hooks', async () => {
const hook = { before: jest.fn() } as unknown as Hook;
OpenFeature.addHooks(hook);
await OpenFeature.clearProviders();
expect(OpenFeature.getHooks()).toHaveLength(1);
});

it('does not clear global evaluation context', async () => {
OpenFeature.setContext({ user: 'test' });
await OpenFeature.clearProviders();
expect(OpenFeature.getContext()).toEqual({ user: 'test' });
});

it('does not remove API-level event handlers', async () => {
const handler = jest.fn();
OpenFeature.addHandler(ProviderEvents.Ready, handler);
await OpenFeature.clearProviders();
expect(OpenFeature.getHandlers(ProviderEvents.Ready)).toHaveLength(1);
});

it('does not reset the transaction context propagator', async () => {
const customPropagator: TransactionContextPropagator = {
getTransactionContext: jest.fn(() => ({ custom: true })),
setTransactionContext: jest.fn(),
};
OpenFeature.setTransactionContextPropagator(customPropagator);
await OpenFeature.clearProviders();
expect(OpenFeature.getTransactionContext()).toEqual({ custom: true });
});
});
});
43 changes: 25 additions & 18 deletions packages/shared/src/open-feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,28 +395,18 @@ export abstract class OpenFeatureCommonAPI<
}

async close(): Promise<void> {
try {
await this?._defaultProvider.provider?.onClose?.();
} catch (err) {
this.handleShutdownError(this._defaultProvider.provider, err);
}

const wrappers = Array.from(this._domainScopedProviders);

await Promise.all(
wrappers.map(async ([, wrapper]) => {
try {
await wrapper?.provider.onClose?.();
} catch (err) {
this.handleShutdownError(wrapper?.provider, err);
}
}),
);
await this._shutdownAllProviders();
this._hooks = [];
this._context = {};
this._domainScopedContext.clear();
this._clientEventHandlers.clear();
this._clientEvents.clear();
this._apiEmitter.removeAllHandlers();
}

protected async clearProvidersAndSetDefault(defaultProvider: P): Promise<void> {
try {
await this.close();
await this._shutdownAllProviders();
} catch (err) {
this._logger.error('Unable to cleanly close providers. Resetting to the default configuration.');
} finally {
Expand All @@ -429,6 +419,23 @@ export abstract class OpenFeatureCommonAPI<
}
}

private async _shutdownAllProviders(): Promise<void> {
const uniqueProviders = new Set<P>([
this._defaultProvider.provider,
...Array.from(this._domainScopedProviders.values()).map((wrapper) => wrapper.provider),
]);

await Promise.all(
Array.from(uniqueProviders).map(async (provider) => {
try {
await provider?.onClose?.();
} catch (err) {
this.handleShutdownError(provider, err);
}
}),
);
}

private get allProviders(): P[] {
return [
...[...this._domainScopedProviders.values()].map((wrappers) => wrappers.provider),
Expand Down
7 changes: 6 additions & 1 deletion packages/web/src/open-feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,13 +361,18 @@ export class OpenFeatureAPI
);
}

async close(): Promise<void> {
await super.close();
this._domainScopedProviders.clear();
this._defaultProvider = new ProviderWrapper(NOOP_PROVIDER, ProviderStatus.NOT_READY, this._statusEnumType);
}

/**
* Clears all registered providers and resets the default provider.
* @returns {Promise<void>}
*/
async clearProviders(): Promise<void> {
await super.clearProvidersAndSetDefault(NOOP_PROVIDER);
this._domainScopedContext.clear();
}

private async runProviderContextChangeHandler(
Expand Down
95 changes: 93 additions & 2 deletions packages/web/test/open-feature.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Paradigm } from '@openfeature/core';
import type { Provider } from '../src';
import { OpenFeature, OpenFeatureAPI, ProviderStatus } from '../src';
import type { Hook, Provider } from '../src';
import { NOOP_PROVIDER, OpenFeature, OpenFeatureAPI, ProviderEvents, ProviderStatus } from '../src';
import { OpenFeatureClient } from '../src/client/internal/open-feature-client';

const mockProvider = (config?: { initialStatus?: ProviderStatus; runsOn?: Paradigm }) => {
Expand Down Expand Up @@ -256,4 +256,95 @@ describe('OpenFeature', () => {
});
});
});

describe('Requirement 1.6.2', () => {
it('resets the default provider to no-op after close()', async () => {
const provider = mockProvider();
OpenFeature.setProvider(provider);
expect(OpenFeature.providerMetadata.name).toBe('mock-events-success');
await OpenFeature.close();
expect(OpenFeature.providerMetadata.name).toBe(NOOP_PROVIDER.metadata.name);
});

it('clears domain-scoped providers after close()', async () => {
const provider = mockProvider();
OpenFeature.setProvider('domain1', provider);
expect(OpenFeature.getProvider('domain1')).toBe(provider);
await OpenFeature.close();
expect(OpenFeature.getProvider('domain1').metadata.name).toBe(NOOP_PROVIDER.metadata.name);
});

it('clears global hooks after close()', async () => {
const hook = { before: jest.fn() } as unknown as Hook;
OpenFeature.addHooks(hook);
expect(OpenFeature.getHooks()).toHaveLength(1);
await OpenFeature.close();
expect(OpenFeature.getHooks()).toHaveLength(0);
});

it('clears global evaluation context after close()', async () => {
await OpenFeature.setContext({ user: 'test' });
expect(OpenFeature.getContext()).toEqual({ user: 'test' });
await OpenFeature.close();
expect(OpenFeature.getContext()).toEqual({});
});

it('clears domain-scoped evaluation context after close()', async () => {
await OpenFeature.setContext('domain1', { user: 'test' });
expect(OpenFeature.getContext('domain1')).toEqual({ user: 'test' });
await OpenFeature.close();
expect(OpenFeature.getContext('domain1')).toEqual({});
});

it('removes API-level event handlers after close()', async () => {
const handler = jest.fn();
OpenFeature.addHandler(ProviderEvents.Ready, handler);
expect(OpenFeature.getHandlers(ProviderEvents.Ready)).toHaveLength(1);
await OpenFeature.close();
expect(OpenFeature.getHandlers(ProviderEvents.Ready)).toHaveLength(0);
});

it('calls provider onClose only once when the same provider instance is registered in multiple scopes', async () => {
const provider = mockProvider();
OpenFeature.setProvider(provider);
OpenFeature.setProvider('domain1', provider);
await OpenFeature.close();
expect(provider.onClose).toHaveBeenCalledTimes(1);
});
});

describe('clearProviders() remains provider-only', () => {
afterEach(async () => {
OpenFeature.clearHooks();
OpenFeature.clearHandlers();
await OpenFeature.setContext({});
});

it('does not clear global hooks', async () => {
const hook = { before: jest.fn() } as unknown as Hook;
OpenFeature.addHooks(hook);
await OpenFeature.clearProviders();
expect(OpenFeature.getHooks()).toHaveLength(1);
});

it('does not clear global evaluation context', async () => {
await OpenFeature.setContext({ user: 'test' });
await OpenFeature.clearProviders();
expect(OpenFeature.getContext()).toEqual({ user: 'test' });
});

it('does not remove API-level event handlers', async () => {
const handler = jest.fn();
OpenFeature.addHandler(ProviderEvents.Ready, handler);
await OpenFeature.clearProviders();
expect(OpenFeature.getHandlers(ProviderEvents.Ready)).toHaveLength(1);
});

it('does not clear domain-scoped evaluation context', async () => {
await OpenFeature.setContext('domain1', { user: 'test' });
await OpenFeature.clearProviders();
expect(OpenFeature.getContext('domain1')).toEqual({ user: 'test' });
await OpenFeature.clearContext('domain1');
});
});
});
Loading