Skip to content

Commit 82f7997

Browse files
committed
feat(27254): implement new remote-feature-flag-controller
1 parent 1e7d70e commit 82f7997

16 files changed

+929
-0
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [0.1.0]
9+
10+
### Added
11+
12+
- Initial release
13+
14+
[Unreleased]: https://github.com/MetaMask/core/
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
MIT License
2+
3+
Copyright (c) 2024 MetaMask
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# `@metamask/example-controllers`
2+
3+
This package is designed to illustrate best practices for controller packages and controller files, including tests.
4+
5+
## Installation
6+
7+
`yarn add @metamask/example-controllers`
8+
9+
or
10+
11+
`npm install @metamask/example-controllers`
12+
13+
## Contributing
14+
15+
This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme).
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* For a detailed explanation regarding each configuration property and type check, visit:
3+
* https://jestjs.io/docs/configuration
4+
*/
5+
6+
const merge = require('deepmerge');
7+
const path = require('path');
8+
9+
const baseConfig = require('../../jest.config.packages');
10+
11+
const displayName = path.basename(__dirname);
12+
13+
module.exports = merge(baseConfig, {
14+
// The display name when running multiple projects
15+
displayName,
16+
17+
// An object that configures minimum threshold enforcement for coverage results
18+
coverageThreshold: {
19+
global: {
20+
branches: 100,
21+
functions: 100,
22+
lines: 100,
23+
statements: 100,
24+
},
25+
},
26+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
{
2+
"name": "@metamask/remote-feature-flag-controller",
3+
"version": "0.0.1",
4+
"private": true,
5+
"description": "Controller with caching, fallback, and privacy for managing feature flags via ClientConfigAPI",
6+
"keywords": [
7+
"MetaMask",
8+
"Ethereum"
9+
],
10+
"homepage": "https://github.com/MetaMask/core/tree/main/packages/remote-feature-flag-controller#readme",
11+
"bugs": {
12+
"url": "https://github.com/MetaMask/core/issues"
13+
},
14+
"repository": {
15+
"type": "git",
16+
"url": "https://github.com/MetaMask/core.git"
17+
},
18+
"license": "MIT",
19+
"sideEffects": false,
20+
"exports": {
21+
".": {
22+
"import": {
23+
"types": "./dist/index.d.mts",
24+
"default": "./dist/index.mjs"
25+
},
26+
"require": {
27+
"types": "./dist/index.d.cts",
28+
"default": "./dist/index.cjs"
29+
}
30+
},
31+
"./package.json": "./package.json"
32+
},
33+
"main": "./dist/index.cjs",
34+
"types": "./dist/index.d.cts",
35+
"files": [
36+
"dist/"
37+
],
38+
"scripts": {
39+
"build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references",
40+
"build:docs": "typedoc",
41+
"changelog:update": "../../scripts/update-changelog.sh @metamask/remote-feature-flag-controllers",
42+
"changelog:validate": "../../scripts/validate-changelog.sh @metamask/remote-feature-flag-controllers",
43+
"publish:preview": "yarn npm publish --tag preview",
44+
"since-latest-release": "../../scripts/since-latest-release.sh",
45+
"test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter",
46+
"test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache",
47+
"test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose",
48+
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
49+
},
50+
"dependencies": {
51+
"@lavamoat/allow-scripts": "^3.3.0",
52+
"@metamask/base-controller": "^7.0.2",
53+
"@metamask/utils": "^10.0.0"
54+
},
55+
"devDependencies": {
56+
"@metamask/auto-changelog": "^3.4.4",
57+
"@metamask/controller-utils": "^11.4.3",
58+
"@types/jest": "^27.4.1",
59+
"deepmerge": "^4.2.2",
60+
"jest": "^27.5.1",
61+
"nock": "^13.3.1",
62+
"ts-jest": "^27.1.4",
63+
"typedoc": "^0.24.8",
64+
"typedoc-plugin-missing-exports": "^2.0.0",
65+
"typescript": "~5.2.2"
66+
},
67+
"engines": {
68+
"node": "^18.18 || >=20"
69+
},
70+
"publishConfig": {
71+
"access": "public",
72+
"registry": "https://registry.npmjs.org/"
73+
}
74+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { PublicInterface } from '@metamask/utils';
2+
3+
import type { ClientConfigApiService } from './client-config-api-service';
4+
5+
/**
6+
* A service object responsible for fetching feature flags.
7+
*/
8+
export type AbstractClientConfigApiService =
9+
PublicInterface<ClientConfigApiService>;
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import type { FeatureFlags } from '../remote-feature-flag-controller-types';
2+
import {
3+
ClientType,
4+
DistributionType,
5+
EnvironmentType,
6+
} from '../remote-feature-flag-controller-types';
7+
import { ClientConfigApiService } from './client-config-api-service';
8+
9+
const BASE_URL = 'https://client-config.api.cx.metamask.io/v1';
10+
11+
describe('ClientConfigApiService', () => {
12+
let originalConsoleError: typeof console.error;
13+
let clientConfigApiService: ClientConfigApiService;
14+
let mockFetch: jest.Mock;
15+
16+
const mockFeatureFlags: FeatureFlags = {
17+
feature1: false,
18+
feature2: { chrome: '<109' },
19+
};
20+
21+
const networkError = new Error('Network error');
22+
Object.assign(networkError, {
23+
response: {
24+
status: 503,
25+
statusText: 'Service Unavailable',
26+
},
27+
});
28+
29+
beforeEach(() => {
30+
mockFetch = jest.fn();
31+
clientConfigApiService = new ClientConfigApiService({ fetch: mockFetch });
32+
});
33+
34+
beforeAll(() => {
35+
originalConsoleError = console.error;
36+
console.error = jest
37+
.spyOn(console, 'error')
38+
.mockImplementation() as unknown as typeof console.error;
39+
});
40+
41+
afterAll(() => {
42+
console.error = originalConsoleError;
43+
});
44+
45+
describe('fetchFlags', () => {
46+
it('should successfully fetch and return feature flags', async () => {
47+
mockFetch.mockResolvedValueOnce({
48+
ok: true,
49+
status: 200,
50+
statusText: 'OK',
51+
json: async () => mockFeatureFlags,
52+
});
53+
54+
const result = await clientConfigApiService.fetchFlags(
55+
ClientType.Extension,
56+
DistributionType.Main,
57+
EnvironmentType.Production,
58+
);
59+
60+
expect(mockFetch).toHaveBeenCalledWith(
61+
`${BASE_URL}/flags?client=extension&distribution=main&environment=prod`,
62+
{ cache: 'no-cache' },
63+
);
64+
65+
expect(result).toStrictEqual({
66+
error: false,
67+
message: 'Success',
68+
statusCode: '200',
69+
statusText: 'OK',
70+
cachedData: mockFeatureFlags,
71+
cacheTimestamp: expect.any(Number),
72+
});
73+
});
74+
75+
it('should return cached data when API request fails and cached data is available', async () => {
76+
const cachedData = { feature3: true };
77+
const cacheTimestamp = Date.now();
78+
79+
mockFetch.mockRejectedValueOnce(networkError);
80+
81+
const result = await clientConfigApiService.fetchFlags(
82+
ClientType.Extension,
83+
DistributionType.Main,
84+
EnvironmentType.Production,
85+
cachedData,
86+
cacheTimestamp,
87+
);
88+
89+
expect(result).toStrictEqual({
90+
error: true,
91+
message: 'Network error',
92+
statusCode: '503',
93+
statusText: 'Service Unavailable',
94+
cachedData,
95+
cacheTimestamp,
96+
});
97+
});
98+
99+
it('should return empty object when API request fails and cached data is not available', async () => {
100+
mockFetch.mockRejectedValueOnce(networkError);
101+
const result = await clientConfigApiService.fetchFlags(
102+
ClientType.Extension,
103+
DistributionType.Main,
104+
EnvironmentType.Production,
105+
);
106+
const currentTime = Date.now();
107+
expect(result).toStrictEqual({
108+
error: true,
109+
message: 'Network error',
110+
statusCode: '503',
111+
statusText: 'Service Unavailable',
112+
cachedData: {},
113+
cacheTimestamp: currentTime,
114+
});
115+
});
116+
117+
it('should handle non-200 responses without cache data', async () => {
118+
mockFetch.mockResolvedValueOnce({
119+
ok: false,
120+
status: 404,
121+
statusText: 'Not Found',
122+
});
123+
124+
const result = await clientConfigApiService.fetchFlags(
125+
ClientType.Extension,
126+
DistributionType.Main,
127+
EnvironmentType.Production,
128+
);
129+
const currentTime = Date.now();
130+
expect(result).toStrictEqual({
131+
error: true,
132+
message: 'Failed to fetch flags',
133+
statusCode: '404',
134+
statusText: 'Not Found',
135+
cachedData: {},
136+
cacheTimestamp: currentTime,
137+
});
138+
});
139+
140+
it('should handle non-200 responses with cache data', async () => {
141+
const cachedData = { feature3: true };
142+
const cacheTimestamp = Date.now();
143+
mockFetch.mockResolvedValueOnce({
144+
ok: false,
145+
status: 404,
146+
statusText: 'Not Found',
147+
});
148+
149+
const result = await clientConfigApiService.fetchFlags(
150+
ClientType.Extension,
151+
DistributionType.Main,
152+
EnvironmentType.Production,
153+
cachedData,
154+
cacheTimestamp,
155+
);
156+
const currentTime = Date.now();
157+
expect(result).toStrictEqual({
158+
error: true,
159+
message: 'Failed to fetch flags',
160+
statusCode: '404',
161+
statusText: 'Not Found',
162+
cachedData,
163+
cacheTimestamp: currentTime,
164+
});
165+
});
166+
167+
it('should handle invalid API responses', async () => {
168+
mockFetch.mockResolvedValueOnce({
169+
ok: true,
170+
status: 200,
171+
statusText: 'OK',
172+
json: async () => null, // Invalid response
173+
});
174+
175+
const result = await clientConfigApiService.fetchFlags(
176+
ClientType.Extension,
177+
DistributionType.Main,
178+
EnvironmentType.Production,
179+
);
180+
181+
const currentTime = Date.now();
182+
expect(result).toStrictEqual({
183+
error: true,
184+
message: 'Invalid API response',
185+
statusCode: null,
186+
statusText: null,
187+
cachedData: {},
188+
cacheTimestamp: currentTime,
189+
});
190+
});
191+
});
192+
});

0 commit comments

Comments
 (0)