Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(27255): allow local modification for remote feature flags #29696

Open
wants to merge 26 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d4f2f3f
feat(27255): allow local modification for remote feature flags
DDDDDanica Jan 14, 2025
887ed35
Update LavaMoat policies
metamaskbot Jan 14, 2025
4cef487
feat(27255): move the file to git ignore and add instructions to Read…
DDDDDanica Jan 16, 2025
5e3788b
feat(29629): Add _flags to webpack build only when in development; an…
DDDDDanica Jan 16, 2025
5c703f2
feat(29629): adapted to right pattern in transform function
DDDDDanica Jan 16, 2025
35fe1f0
feat(29629): relocate `getManifestFlags` to `shared/lib/manifestFlags`
DDDDDanica Jan 16, 2025
473a3bd
feat(29629): enrich `getRemoteFeatureFlags` return type
DDDDDanica Jan 16, 2025
caa4b4f
feat(29629): shallow merge states in `getRemoteFeatureFlags`
DDDDDanica Jan 16, 2025
0bff314
Update LavaMoat policies
metamaskbot Jan 16, 2025
d4e79a7
feat(29629): fix e2e test due to shallow merging
DDDDDanica Jan 17, 2025
8e88e7f
feat(29629): Update readme
DDDDDanica Jan 17, 2025
b1a52ae
feat(29629): Remove unused [] param in getWebpackConfig test
DDDDDanica Jan 17, 2025
b2b5862
feat(29629): Remove unnecessary --test flag in non-default options te…
DDDDDanica Jan 17, 2025
a5ea54f
feat(29629): Remove comment in json file to avoid copy error; read .m…
DDDDDanica Jan 17, 2025
5308bed
feat(29629): Move getRemoteFeatureFlags selector to a separate ts file
DDDDDanica Jan 19, 2025
2cc1560
feat(29629): Rename remote-feature-flags.ts file name
DDDDDanica Jan 19, 2025
248ec01
feat(29629): Refactor getRemoteFeatureFlags to use safe merge from lo…
DDDDDanica Jan 19, 2025
40f3c07
feat(29629): Rename gitnore comment
DDDDDanica Jan 19, 2025
3a16019
feat(29629): Remove asyn reading for loadManifestFlags in normal build
DDDDDanica Jan 19, 2025
77ad1e5
feat(29629): Fix unit test of file not exist
DDDDDanica Jan 19, 2025
b09f6a5
feat(29629): Fix lint
DDDDDanica Jan 20, 2025
9487c17
Merge branch 'main' into feature/remote-feature-flags-manifest-adaption
DDDDDanica Jan 26, 2025
51b6de6
fix(3742): fix lint
DDDDDanica Jan 26, 2025
f3c8e38
feat(27255): import json file from `.metamaskrc` instead of using rea…
DDDDDanica Jan 31, 2025
2c5ab32
feat(27255): fix lint
DDDDDanica Jan 31, 2025
945c42a
feat(27255): removed console log in build console
DDDDDanica Jan 31, 2025
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ notes.txt
.metamaskrc
.metamaskprodrc

# Customized manifest configuration
.manifest-overrides.json
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest ignoring .manifest-overrides*.json

Because remember we want people to be able to create a few of these and toggle between them, plus be able to put them in folders, and gitignore all of these.


# Test results
test-results/

Expand Down
10 changes: 10 additions & 0 deletions .metamaskrc.dist
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,13 @@ BLOCKAID_PUBLIC_KEY=
; API key used in Etherscan requests to prevent rate limiting.
; Only applies to Mainnet and Sepolia.
; ETHERSCAN_API_KEY=

; A JSON config file that can be used to override the default manifest values.
; e.g., `.manifest-overrides.json` where the contents might be something like:
; {
; "_flags": {
; "remoteFeatureFlags": { }
; }
; }
; Note: Properties are shallow merged into the manifest.json file at build time.
;MANIFEST_OVERRIDES=.manifest-overrides.json
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ If you are not a MetaMask Internal Developer, or are otherwise developing on a f
- If debugging MetaMetrics, you'll need to add a value for `SEGMENT_WRITE_KEY` [Segment write key](https://segment.com/docs/connections/find-writekey/), see [Developing on MetaMask - Segment](./development/README.md#segment).
- If debugging unhandled exceptions, you'll need to add a value for `SENTRY_DSN` [Sentry Dsn](https://docs.sentry.io/product/sentry-basics/dsn-explainer/), see [Developing on MetaMask - Sentry](./development/README.md#sentry).
- Optionally, replace the `PASSWORD` value with your development wallet password to avoid entering it each time you open the app.
- If developing with remote feature flags and you want to override the flags in the build process, you can add a `.manifest-overrides.json` file to the root of the project and set `MANIFEST_OVERRIDES=.manifest-overrides.json` in `.metamaskrc` to the path of the file. This file is used to add flags to `manifest.json` build files for the extension. You can also modify the `_flags.remoteFeatureFlags` in `manifest.json` file to tweak the flags after the build process.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trying to make the instructions slightly clearer

Suggested change
- If developing with remote feature flags and you want to override the flags in the build process, you can add a `.manifest-overrides.json` file to the root of the project and set `MANIFEST_OVERRIDES=.manifest-overrides.json` in `.metamaskrc` to the path of the file. This file is used to add flags to `manifest.json` build files for the extension. You can also modify the `_flags.remoteFeatureFlags` in `manifest.json` file to tweak the flags after the build process.
- If developing with remote feature flags and you want to override the flags in the build process, you can add a `.manifest-overrides.json` file to the root of the project and set `MANIFEST_OVERRIDES=.manifest-overrides.json` in `.metamaskrc` to the path of the file. This file is used to add flags to `manifest.json` build files for the extension. You can also modify the `_flags.remoteFeatureFlags` in the built version of `manifest.json` in the `dist/browser` folder to tweak the flags after the build process (these changes will get overwritten when you build again).

```
- Run `yarn install` to install the dependencies.
- Build the project to the `./dist/` folder with `yarn dist` (for Chromium-based browsers) or `yarn dist:mv2` (for Firefox)
Expand Down
2 changes: 1 addition & 1 deletion app/scripts/lib/setupSentry.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import * as Sentry from '@sentry/browser';
import { logger } from '@sentry/utils';
import browser from 'webextension-polyfill';
import { isManifestV3 } from '../../../shared/modules/mv3.utils';
import { getManifestFlags } from '../../../shared/lib/manifestFlags';
import extractEthjsErrorMessage from './extractEthjsErrorMessage';
import { getManifestFlags } from './manifestFlags';
import { filterEvents } from './sentry-filter-events';

const projectLogger = createProjectLogger('sentry');
Expand Down
2 changes: 2 additions & 0 deletions builds.yml
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,8 @@ env:

# Uses yaml anchors to DRY - https://juju.is/docs/sdk/yaml-anchors-and-aliases
- METAMASK_BUILD_TYPE_DEFAULT: *default
# Path to a JSON file that will be used to override the default manifest values.
- MANIFEST_OVERRIDES: null

###
# Account Abstraction (EIP-4337)
Expand Down
1 change: 1 addition & 0 deletions development/build/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,4 +160,5 @@ async function getConfig(buildType, environment) {

module.exports = {
getConfig,
fromIniFile,
};
33 changes: 31 additions & 2 deletions development/build/manifest.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const { promises: fs } = require('fs');
const { readFileSync } = require('node:fs');
const path = require('path');
const childProcess = require('child_process');
const { mergeWith, cloneDeep } = require('lodash');
Expand All @@ -15,9 +16,35 @@ const { loadBuildTypesConfig } = require('../lib/build-type');
const { TASKS, ENVIRONMENT } = require('./constants');
const { createTask, composeSeries } = require('./task');
const { getEnvironment, getBuildName } = require('./utils');
const { fromIniFile } = require('./config');

module.exports = createManifestTasks;

async function loadManifestFlags() {
const { definitions } = await fromIniFile(
path.resolve(__dirname, '..', '..', '.metamaskrc'),
);
const manifestOverridesPath = definitions.get('MANIFEST_OVERRIDES');
// default to undefined so that the manifest plugin can check if it was set
let manifestFlags;
if (manifestOverridesPath) {
try {
manifestFlags = await readJson(
path.resolve(process.cwd(), manifestOverridesPath),
);
} catch (error) {
// Only throw if error is not ENOENT (file not found) and manifestOverridesPath was provided
if (error.code === 'ENOENT') {
throw new Error(
`Manifest override file not found: ${manifestOverridesPath}`,
);
}
}
}

return manifestFlags;
}

function createManifestTasks({
browserPlatforms,
browserVersionMap,
Expand All @@ -28,6 +55,7 @@ function createManifestTasks({
}) {
// merge base manifest with per-platform manifests
const prepPlatforms = async () => {
const manifestFlags = await loadManifestFlags();
return Promise.all(
browserPlatforms.map(async (platform) => {
const platformModifications = await readJson(
Expand All @@ -47,8 +75,9 @@ function createManifestTasks({
browserVersionMap[platform],
await getBuildModifications(buildType, platform),
customArrayMerge,
// Only include _flags if manifestFlags has content
manifestFlags && { _flags: manifestFlags },
);

modifyNameAndDescForNonProd(result);

const dir = path.join('.', 'dist', platform);
Expand Down Expand Up @@ -160,7 +189,7 @@ function createManifestTasks({

// helper for reading and deserializing json from fs
async function readJson(file) {
return JSON.parse(await fs.readFile(file, 'utf8'));
return JSON.parse(await readFileSync(file, 'utf8'));
}

// helper for serializing and writing json to fs
Expand Down
2 changes: 1 addition & 1 deletion development/lib/get-manifest-flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { exec as callbackExec } from 'node:child_process';
import { hasProperty } from '@metamask/utils';
import { merge } from 'lodash';

import type { ManifestFlags } from '../../app/scripts/lib/manifestFlags';
import type { ManifestFlags } from '../../shared/lib/manifestFlags';

const exec = promisify(callbackExec);
const PR_BODY_FILEPATH = path.resolve(
Expand Down
65 changes: 64 additions & 1 deletion development/webpack/test/plugins.ManifestPlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ describe('ManifestPlugin', () => {
function runTest(baseManifest: Combination<typeof manifestMatrix>) {
const manifest = baseManifest as unknown as chrome.runtime.Manifest;
const hasTabsPermission = (manifest.permissions || []).includes('tabs');
const transform = transformManifest(args);
const transform = transformManifest(args, false);

if (args.test && hasTabsPermission) {
it("throws in test mode when manifest already contains 'tabs' permission", () => {
Expand Down Expand Up @@ -281,4 +281,67 @@ describe('ManifestPlugin', () => {
}
}
});

describe('manifest flags in development mode', () => {
const testManifest = {} as chrome.runtime.Manifest;
const mockFlags = { remoteFeatureFlags: { testFlag: true } };
const manifestOverridesPath = 'testManifestOverridesPath.json';

it('adds manifest flags in development mode with path provided', () => {
const transform = transformManifest(
{ lockdown: true, test: false },
true,
manifestOverridesPath,
);
assert(transform, 'transform should be truthy');

// Mock fs.readFileSync
const fs = require('fs');
const originalReadFileSync = fs.readFileSync;
fs.readFileSync = () => JSON.stringify(mockFlags);
Comment on lines +298 to +301
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT:

Node's testing library has mocking built it. I think the right way to do this is to create a before block within this new 'manifest flags in development mode' describe block:

    // (note: this is untested)
    const originalReadFileSync = fs.readFileSync;
    before("mock `.manifest-flags.json`", () => {
      mock.method(fs, 'readFileSync', (path: string, options: object) => {
        // only override reads for `.manifest-flags.json`:
        if (path === resolve(__dirname, <the path to the file here>)) {
          // mock `.manifest-flags.json`
          return JSON.stringify(mockFlags);
        }
        return originalReadFileSync(path, options);
      });
    });
    
    after(() => mock.restoreAll());


try {
const transformed = transform(testManifest, 'chrome');
assert.deepStrictEqual(
transformed._flags,
mockFlags,
'manifest should have flags in development mode',
);
} finally {
// Restore original readFileSync
fs.readFileSync = originalReadFileSync;
}
});

it('handles missing manifest flags file with path provided', () => {
const transform = transformManifest(
{ lockdown: true, test: false },
true,
manifestOverridesPath,
);
assert(transform, 'transform should be truthy');

// Mock fs.readFileSync to throw ENOENT
const fs = require('node:fs');
const originalReadFileSync = fs.readFileSync;
fs.readFileSync = () => {
const error = new Error('File not found') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
};

try {
assert.throws(
() => transform(testManifest, 'chrome'),
{
message: `Manifest override file not found: ${manifestOverridesPath}`,
},
'should throw when manifest override file is not found',
);
} finally {
// Restore original readFileSync
fs.readFileSync = originalReadFileSync;
}
});
});
});
2 changes: 1 addition & 1 deletion development/webpack/test/webpack.config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ ${Object.entries(env)
assert.deepStrictEqual(manifestPlugin.options.description, null);
assert.deepStrictEqual(manifestPlugin.options.zip, true);
assert(manifestPlugin.options.zipOptions, 'Zip options should be present');
assert.strictEqual(manifestPlugin.options.transform, undefined);
assert.deepStrictEqual(manifestPlugin.options.transform, undefined);

const progressPlugin = instance.options.plugins.find(
(plugin) => plugin && plugin.constructor.name === 'ProgressPlugin',
Expand Down
50 changes: 49 additions & 1 deletion development/webpack/utils/plugins/ManifestPlugin/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,17 @@
* @param args
* @param args.lockdown
* @param args.test
* @param isDevelopment
* @param manifestOverridesPath
* @returns a function that will transform the manifest JSON object
* @throws an error if the manifest already contains the "tabs" permission and
* `test` is `true`
*/
export function transformManifest(args: { lockdown: boolean; test: boolean }) {
export function transformManifest(
args: { lockdown: boolean; test: boolean },
isDevelopment: boolean,
manifestOverridesPath?: string,
) {
const transforms: ((manifest: chrome.runtime.Manifest) => void)[] = [];

function removeLockdown(browserManifest: chrome.runtime.Manifest) {
Expand All @@ -29,6 +35,48 @@ export function transformManifest(args: { lockdown: boolean; test: boolean }) {
transforms.push(removeLockdown);
}

/**
davidmurdoch marked this conversation as resolved.
Show resolved Hide resolved
* This function sets predefined flags in the manifest's _flags property
* that are stored in the .manifest-flags.json file.
*
* @param browserManifest - The Chrome extension manifest object to modify
*/
function addManifestFlags(browserManifest: chrome.runtime.Manifest) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Add customized local manifestFlags to manifest file (webpack build system)

let manifestFlags;

if (manifestOverridesPath) {
try {
const fs = require('node:fs');
const path = require('node:path');
const manifestFlagsContent = fs.readFileSync(
path.resolve(process.cwd(), manifestOverridesPath),
'utf8',
);
manifestFlags = JSON.parse(manifestFlagsContent);
} catch (error: unknown) {
if (
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT'
) {
// Only throw if ENOENT and manifestOverridesPath was provided
throw new Error(
`Manifest override file not found: ${manifestOverridesPath}`,
);
}
}
}

if (manifestFlags) {
browserManifest._flags = manifestFlags;
}
}

if (isDevelopment) {
// Add manifest flags only for development builds
transforms.push(addManifestFlags);
}

function addTabsPermission(browserManifest: chrome.runtime.Manifest) {
if (browserManifest.permissions) {
if (browserManifest.permissions.includes('tabs')) {
Expand Down
6 changes: 5 additions & 1 deletion development/webpack/webpack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,11 @@
version: version.version,
versionName: version.versionName,
browsers: args.browser,
transform: transformManifest(args),
transform: transformManifest(
args,
isDevelopment,
variables.get('MANIFEST_OVERRIDES'),

Check failure on line 137 in development/webpack/webpack.config.ts

View workflow job for this annotation

GitHub Actions / Test lint / Test lint

Argument of type 'unknown' is not assignable to parameter of type 'string | undefined'.
),
zip: args.zip,
...(args.zip
? {
Expand Down
12 changes: 12 additions & 0 deletions app/scripts/lib/manifestFlags.ts → shared/lib/manifestFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ export type ManifestFlags = {
*/
forceEnable?: boolean;
};
/**
* Feature flags to control business logic behavior
*/
remoteFeatureFlags?: {
/**
* A test remote featureflag for threshold
*/
testFlagForThreshold: {
name: string;
value: string;
};
Comment on lines +70 to +73
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we use better types here? maybe something like testFlagForThreshold: Record<string, any> ( or maybe unknown instead of any, but that may complicate some things).

Or if you really want to go the extra mile, you can define the type as any valid JSON:

type JSONValue =
  | string
  | number
  | boolean
  | null
  | JSONValue[]
  | { [key: string]: JSONValue };

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this one, the name and value are the same in request, and controller will choose one of the value to return based on threshold => https://client-config.api.cx.metamask.io/v1/flags?client=extension&distribution=main&environment=dev

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, so it's an array of name, value, and scope properties? I think this type should reflect that

};
};

// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- you can't extend a type, we want this to be an interface
Expand Down
18 changes: 18 additions & 0 deletions test/e2e/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,21 @@ export enum ACCOUNT_TYPE {
/* Meta metricsId generated by generateMetaMetricsId */
export const MOCK_META_METRICS_ID =
'0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420';

/* Mock remote feature flags response */
export const MOCK_REMOTE_FEATURE_FLAGS_RESPONSE = {
feature1: true,
feature2: false,
feature3: {
name: 'groupC',
value: 'valueC',
},
};

/* Mock customized remote feature flags response*/
export const MOCK_CUSTOMIZED_REMOTE_FEATURE_FLAGS = {
feature3: {
name: 'groupA',
value: 'valueA',
},
};
23 changes: 23 additions & 0 deletions test/e2e/page-objects/pages/developer-options-page.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { strict as assert } from 'assert';
import { Driver } from '../../webdriver/driver';
import {
MOCK_REMOTE_FEATURE_FLAGS_RESPONSE,
MOCK_CUSTOMIZED_REMOTE_FEATURE_FLAGS,
} from '../../constants';

class DevelopOptions {
private readonly driver: Driver;
Expand All @@ -12,6 +17,9 @@ class DevelopOptions {
css: 'h4',
};

private readonly developerOptionsRemoteFeatureFlagsState: string =
'[data-testid="developer-options-remote-feature-flags"]';

constructor(driver: Driver) {
this.driver = driver;
}
Expand All @@ -33,6 +41,21 @@ class DevelopOptions {
console.log('Generate a page crash in Developer option page');
await this.driver.clickElement(this.generatePageCrashButton);
}

async validateRemoteFeatureFlagState(): Promise<void> {
console.log('Validate remote feature flags state in Developer option page');
const element = await this.driver.findElement(
this.developerOptionsRemoteFeatureFlagsState,
);
const remoteFeatureFlagsState = await element.getText();
assert.equal(
remoteFeatureFlagsState,
JSON.stringify({
...MOCK_REMOTE_FEATURE_FLAGS_RESPONSE,
...MOCK_CUSTOMIZED_REMOTE_FEATURE_FLAGS,
}),
);
}
}

export default DevelopOptions;
2 changes: 1 addition & 1 deletion test/e2e/set-manifest-flags.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fs from 'fs';
import { merge } from 'lodash';
import { ManifestFlags } from '../../app/scripts/lib/manifestFlags';
import { ManifestFlags } from '../../shared/lib/manifestFlags';
import { fetchManifestFlagsFromPRAndGit } from '../../development/lib/get-manifest-flag';

export const folder = `dist/${process.env.SELENIUM_BROWSER}`;
Expand Down
Loading
Loading