diff --git a/packages/nest/test/open-feature.module.spec.ts b/packages/nest/test/open-feature.module.spec.ts index 38e23c5867..c0dc785fb4 100644 --- a/packages/nest/test/open-feature.module.spec.ts +++ b/packages/nest/test/open-feature.module.spec.ts @@ -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(getOpenFeatureClientToken()); - }).not.toThrow(); - }); - }); - it('should return the default provider', async () => { const client = moduleRef.get(getOpenFeatureClientToken()); expect(client).toBeDefined(); @@ -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(getOpenFeatureClientToken()); + }).not.toThrow(); + }); + }); }); describe('handlers', () => { diff --git a/packages/server/src/open-feature.ts b/packages/server/src/open-feature.ts index 1bc491937d..511325134e 100644 --- a/packages/server/src/open-feature.ts +++ b/packages/server/src/open-feature.ts @@ -219,6 +219,13 @@ export class OpenFeatureAPI ); } + async close(): Promise { + 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} diff --git a/packages/server/test/open-feature.spec.ts b/packages/server/test/open-feature.spec.ts index fe41f83542..8e91e3d475 100644 --- a/packages/server/test/open-feature.spec.ts +++ b/packages/server/test/open-feature.spec.ts @@ -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 }) => { @@ -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 }); + }); + }); }); diff --git a/packages/shared/src/open-feature.ts b/packages/shared/src/open-feature.ts index 00142b5670..d491cac450 100644 --- a/packages/shared/src/open-feature.ts +++ b/packages/shared/src/open-feature.ts @@ -395,28 +395,18 @@ export abstract class OpenFeatureCommonAPI< } async close(): Promise { - 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 { try { - await this.close(); + await this._shutdownAllProviders(); } catch (err) { this._logger.error('Unable to cleanly close providers. Resetting to the default configuration.'); } finally { @@ -429,6 +419,23 @@ export abstract class OpenFeatureCommonAPI< } } + private async _shutdownAllProviders(): Promise { + const uniqueProviders = new Set

([ + 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), diff --git a/packages/web/src/open-feature.ts b/packages/web/src/open-feature.ts index 131f8c2731..9d378dc062 100644 --- a/packages/web/src/open-feature.ts +++ b/packages/web/src/open-feature.ts @@ -361,13 +361,18 @@ export class OpenFeatureAPI ); } + async close(): Promise { + 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} */ async clearProviders(): Promise { await super.clearProvidersAndSetDefault(NOOP_PROVIDER); - this._domainScopedContext.clear(); } private async runProviderContextChangeHandler( diff --git a/packages/web/test/open-feature.spec.ts b/packages/web/test/open-feature.spec.ts index 308d8f43b4..e6af0eedb2 100644 --- a/packages/web/test/open-feature.spec.ts +++ b/packages/web/test/open-feature.spec.ts @@ -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 }) => { @@ -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'); + }); + }); });