diff --git a/packages/snap-networks-utils/CHANGELOG.md b/packages/snap-networks-utils/CHANGELOG.md index 1e0bd139..dd896718 100644 --- a/packages/snap-networks-utils/CHANGELOG.md +++ b/packages/snap-networks-utils/CHANGELOG.md @@ -9,6 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Initial package release ([#82](https://github.com/MetaMask/internal-snaps/pull/82), [#79](https://github.com/MetaMask/internal-snaps/pull/79)) +- Initial package release ([#99](https://github.com/MetaMask/internal-snaps/pull/99), [#82](https://github.com/MetaMask/internal-snaps/pull/82), [#79](https://github.com/MetaMask/internal-snaps/pull/79)) [Unreleased]: https://github.com/MetaMask/internal-snaps/ diff --git a/packages/snap-networks-utils/package.json b/packages/snap-networks-utils/package.json index d094babb..e8955cf6 100644 --- a/packages/snap-networks-utils/package.json +++ b/packages/snap-networks-utils/package.json @@ -62,6 +62,7 @@ }, "dependencies": { "@metamask/assets-controller": "^13.0.0", + "@metamask/remote-feature-flag-controller": "^5.0.0", "@metamask/snaps-sdk": "^11.2.0", "@metamask/utils": "^11.9.0" }, diff --git a/packages/snap-networks-utils/src/providers/remote-feature-flag/RemoteFeatureFlagProvider.test.ts b/packages/snap-networks-utils/src/providers/remote-feature-flag/RemoteFeatureFlagProvider.test.ts new file mode 100644 index 00000000..7ded1ecd --- /dev/null +++ b/packages/snap-networks-utils/src/providers/remote-feature-flag/RemoteFeatureFlagProvider.test.ts @@ -0,0 +1,111 @@ +import type { RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller'; + +import type { RemoteFeatureFlagProviderMessenger } from './RemoteFeatureFlagProvider'; +import { RemoteFeatureFlagProvider } from './RemoteFeatureFlagProvider'; + +const FLAG_KEY_A = 'flag-a'; +const FLAG_KEY_B = 'flag-b'; + +const DEFAULT_STATE: RemoteFeatureFlagControllerState = { + remoteFeatureFlags: { + [FLAG_KEY_A]: 'value-a', + [FLAG_KEY_B]: 2, + }, + cacheTimestamp: 0, +}; + +type WithRemoteFeatureFlagProviderCallback = (payload: { + remoteFeatureFlagProvider: RemoteFeatureFlagProvider; + mockMessenger: jest.Mocked; +}) => Promise | ReturnValue; + +/** + * Wraps tests for RemoteFeatureFlagProvider by creating a fresh provider with a + * mock messenger. The callback receives the provider and mock for test configuration. + * + * @param testFunction - The test body receiving the provider and mocks. + * @returns The return value of the callback. + */ +async function withRemoteFeatureFlagProvider( + testFunction: WithRemoteFeatureFlagProviderCallback, +): Promise { + const mockMessenger: jest.Mocked = { + call: jest.fn().mockResolvedValue(DEFAULT_STATE), + }; + + const remoteFeatureFlagProvider = new RemoteFeatureFlagProvider({ + messenger: mockMessenger, + }); + + return await testFunction({ + remoteFeatureFlagProvider, + mockMessenger, + }); +} + +describe('RemoteFeatureFlagProvider', () => { + describe('getFeatureFlag', () => { + it('calls RemoteFeatureFlagController:getState and returns the flag value', async () => { + await withRemoteFeatureFlagProvider( + async ({ remoteFeatureFlagProvider, mockMessenger }) => { + const value = + await remoteFeatureFlagProvider.getFeatureFlag(FLAG_KEY_A); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RemoteFeatureFlagController:getState', + ); + expect(mockMessenger.call).toHaveBeenCalledTimes(1); + expect(value).toBe('value-a'); + }, + ); + }); + + it('returns undefined when the flag key is missing', async () => { + await withRemoteFeatureFlagProvider( + async ({ remoteFeatureFlagProvider }) => { + const value = + await remoteFeatureFlagProvider.getFeatureFlag('missing-flag'); + + expect(value).toBeUndefined(); + }, + ); + }); + }); + + describe('getFeatureFlags', () => { + it('calls RemoteFeatureFlagController:getState once and returns a keyed map', async () => { + await withRemoteFeatureFlagProvider( + async ({ remoteFeatureFlagProvider, mockMessenger }) => { + const values = await remoteFeatureFlagProvider.getFeatureFlags([ + FLAG_KEY_A, + FLAG_KEY_B, + 'missing-flag', + ]); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RemoteFeatureFlagController:getState', + ); + expect(mockMessenger.call).toHaveBeenCalledTimes(1); + expect(values).toStrictEqual({ + [FLAG_KEY_A]: 'value-a', + [FLAG_KEY_B]: 2, + 'missing-flag': undefined, + }); + }, + ); + }); + + it('returns an empty map when no keys are requested', async () => { + await withRemoteFeatureFlagProvider( + async ({ remoteFeatureFlagProvider, mockMessenger }) => { + const values = await remoteFeatureFlagProvider.getFeatureFlags([]); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RemoteFeatureFlagController:getState', + ); + expect(values).toStrictEqual({}); + }, + ); + }); + }); +}); diff --git a/packages/snap-networks-utils/src/providers/remote-feature-flag/RemoteFeatureFlagProvider.ts b/packages/snap-networks-utils/src/providers/remote-feature-flag/RemoteFeatureFlagProvider.ts new file mode 100644 index 00000000..6488a589 --- /dev/null +++ b/packages/snap-networks-utils/src/providers/remote-feature-flag/RemoteFeatureFlagProvider.ts @@ -0,0 +1,72 @@ +import type { Messenger } from '@metamask/messenger'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; +import { AsyncMessenger } from '@metamask/snaps-sdk'; +import type { Json } from '@metamask/utils'; + +/** + * Namespace for the {@link RemoteFeatureFlagProvider} messenger. + */ +export const REMOTE_FEATURE_FLAG_PROVIDER_NAME = + 'RemoteFeatureFlagProvider' as const; + +/** + * Actions from other messengers that {@link RemoteFeatureFlagProvider} calls. + */ +export type RemoteFeatureFlagProviderAllowedActions = + RemoteFeatureFlagControllerGetStateAction; + +/** + * Messenger restricted to actions consumed by {@link RemoteFeatureFlagProvider}. + */ +export type RemoteFeatureFlagProviderMessenger = AsyncMessenger< + Messenger< + typeof REMOTE_FEATURE_FLAG_PROVIDER_NAME, + RemoteFeatureFlagProviderAllowedActions + > +>; + +export class RemoteFeatureFlagProvider { + readonly #messenger: RemoteFeatureFlagProviderMessenger; + + constructor({ + messenger, + }: { + messenger: RemoteFeatureFlagProviderMessenger; + }) { + this.#messenger = messenger; + } + + /** + * Returns a single remote feature flag value, or `undefined` if missing. + * + * @param flagKey - LaunchDarkly / client-config feature flag key. + * @returns Flag value, or `undefined`. + */ + async getFeatureFlag(flagKey: string): Promise { + const { remoteFeatureFlags } = await this.#messenger.call( + 'RemoteFeatureFlagController:getState', + ); + + return remoteFeatureFlags[flagKey]; + } + + /** + * Returns remote feature flag values for the given keys, keyed by flag key. + * Missing keys are included with value `undefined`. + * + * @param flagKeys - Feature flag keys to resolve. + * @returns Map of flag key → value. + */ + async getFeatureFlags( + flagKeys: string[], + ): Promise> { + const { remoteFeatureFlags } = await this.#messenger.call( + 'RemoteFeatureFlagController:getState', + ); + + return flagKeys.reduce>((acc, flagKey) => { + acc[flagKey] = remoteFeatureFlags[flagKey]; + return acc; + }, {}); + } +} diff --git a/yarn.lock b/yarn.lock index 6e5ceba7..d12f3131 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3299,6 +3299,7 @@ __metadata: "@metamask/assets-controller": "npm:^13.0.0" "@metamask/auto-changelog": "npm:^6.1.1" "@metamask/messenger": "npm:^2.0.0" + "@metamask/remote-feature-flag-controller": "npm:^5.0.0" "@metamask/snaps-sdk": "npm:^11.2.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4"