@deeeed/metamask-harness 0.58.0 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +48 -0
  3. package/adapters/extension/build-handoff.cjs +4 -0
  4. package/adapters/extension/inject.mjs +0 -1
  5. package/adapters/extension/live.sh +26 -25
  6. package/adapters/extension/pin-remote-flags.cjs +69 -30
  7. package/adapters/manifest.json +4 -4
  8. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +16 -0
  9. package/adapters/mobile/bridge-runtime/setup-wallet.sh +69 -2
  10. package/adapters/shared/resolve-slot-ports-core.mjs +47 -19
  11. package/dist/adapters/extension/feature-flags.js +41 -0
  12. package/dist/adapters/extension/runtime-decision.js +2 -2
  13. package/dist/adapters/extension/runtime.js +4 -3
  14. package/dist/adapters/extension/verify.js +608 -0
  15. package/dist/adapters/mobile/feature-flags.js +127 -0
  16. package/dist/adapters/resolve-slot-ports.js +2 -0
  17. package/dist/adapters.js +6 -1
  18. package/dist/cli.js +1 -1
  19. package/dist/command-contract.js +6 -3
  20. package/dist/commands/doctor.js +9 -1
  21. package/dist/commands/fixtures.js +45 -3
  22. package/dist/commands/launch/extension.js +28 -6
  23. package/dist/commands/launch/index.js +128 -17
  24. package/dist/commands/launch/mobile.js +26 -2
  25. package/dist/commands/mobile-device-view.js +8 -1
  26. package/dist/commands/prepare.js +20 -3
  27. package/dist/commands/run-engine.js +10 -1
  28. package/dist/commands/runtime-launch.js +10 -1
  29. package/dist/commands/status-probe.js +14 -1
  30. package/dist/commands/status.js +13 -0
  31. package/dist/doctor.js +9 -1
  32. package/dist/feature-flags.js +193 -0
  33. package/dist/harness.js +3 -15
  34. package/dist/mm-harness-cli.js +15 -2
  35. package/library/actions/core/feature_flags/_unsupported.mjs +29 -0
  36. package/library/actions/core/feature_flags/clear.mjs +8 -0
  37. package/library/actions/core/feature_flags/read.mjs +8 -0
  38. package/library/actions/core/feature_flags/set.mjs +8 -0
  39. package/library/actions/extension/feature_flags/clear.mjs +48 -0
  40. package/library/actions/extension/feature_flags/read.mjs +27 -0
  41. package/library/actions/extension/feature_flags/set.mjs +36 -0
  42. package/library/actions/extension/feature_flags/state.mjs +129 -0
  43. package/library/actions/harness-exports.mjs +14 -0
  44. package/library/actions/mobile/feature_flags/clear.mjs +20 -0
  45. package/library/actions/mobile/feature_flags/controller.mjs +120 -0
  46. package/library/actions/mobile/feature_flags/read.mjs +20 -0
  47. package/library/actions/mobile/feature_flags/set.mjs +25 -0
  48. package/library/actions/mobile/wallet/reset.mjs +22 -1
  49. package/library/manifests/core.action-manifest.json +90 -0
  50. package/library/manifests/extension.action-manifest.json +70 -0
  51. package/library/manifests/mobile.action-manifest.json +90 -0
  52. package/package.json +1 -1
  53. package/adapters/extension/verify.sh +0 -572
@@ -0,0 +1,193 @@
1
+ import { readFileSync } from "node:fs";
2
+ const REMOTE_FLAG_USAGE = 'pass --remote-flag KEY=VALUE[,KEY=VALUE] \u2014 VALUE is a variant name (treatment), true/false, or JSON ({"enabled":true})';
3
+ class FeatureFlagSpecError extends Error {
4
+ userAction;
5
+ constructor(message, userAction = REMOTE_FLAG_USAGE) {
6
+ super(message);
7
+ this.name = "FeatureFlagSpecError";
8
+ this.userAction = userAction;
9
+ }
10
+ }
11
+ const KEY_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$.-]*$/u;
12
+ function assertKey(key, where) {
13
+ if (!key) {
14
+ throw new FeatureFlagSpecError(`${where} has an empty feature-flag key.`);
15
+ }
16
+ if (!KEY_PATTERN.test(key)) {
17
+ throw new FeatureFlagSpecError(
18
+ `${where} feature-flag key ${JSON.stringify(key)} is not a flag name; expected a remote feature-flag key such as perpsTAT3938AbtestScreenVsBottomSheet.`
19
+ );
20
+ }
21
+ }
22
+ function normalizeFeatureFlagValue(raw, where) {
23
+ if (typeof raw !== "string") {
24
+ if (raw === null) return null;
25
+ if (typeof raw === "boolean" || typeof raw === "number") return raw;
26
+ if (typeof raw === "object") return raw;
27
+ throw new FeatureFlagSpecError(
28
+ `${where} feature-flag value has unsupported type ${typeof raw}; use a variant name, true/false, or JSON.`
29
+ );
30
+ }
31
+ const value = raw.trim();
32
+ if (!value) {
33
+ throw new FeatureFlagSpecError(
34
+ `${where} has an empty feature-flag value; a flag needs a variant name, true/false, or JSON.`
35
+ );
36
+ }
37
+ if (value === "true") return true;
38
+ if (value === "false") return false;
39
+ if (value.startsWith("{") || value.startsWith("[")) {
40
+ try {
41
+ return JSON.parse(value);
42
+ } catch (error) {
43
+ throw new FeatureFlagSpecError(
44
+ `${where} feature-flag value is not valid JSON: ${value} (${error instanceof Error ? error.message : String(error)}).`
45
+ );
46
+ }
47
+ }
48
+ return { name: value };
49
+ }
50
+ function splitPairs(spec) {
51
+ const pairs = [];
52
+ let depth = 0;
53
+ let inString = false;
54
+ let escaped = false;
55
+ let current = "";
56
+ for (const char of spec) {
57
+ if (inString) {
58
+ current += char;
59
+ if (escaped) escaped = false;
60
+ else if (char === "\\") escaped = true;
61
+ else if (char === '"') inString = false;
62
+ continue;
63
+ }
64
+ if (char === '"') {
65
+ inString = true;
66
+ current += char;
67
+ continue;
68
+ }
69
+ if (char === "{" || char === "[") depth += 1;
70
+ if (char === "}" || char === "]") depth -= 1;
71
+ if (char === "," && depth <= 0) {
72
+ pairs.push(current);
73
+ current = "";
74
+ continue;
75
+ }
76
+ current += char;
77
+ }
78
+ pairs.push(current);
79
+ return pairs.map((pair) => pair.trim()).filter((pair) => pair.length > 0);
80
+ }
81
+ function parseRemoteFlagSpec(spec, where = "--remote-flag") {
82
+ const pairs = splitPairs(spec);
83
+ if (pairs.length === 0) {
84
+ throw new FeatureFlagSpecError(`${where} had no KEY=VALUE pairs: ${JSON.stringify(spec)}.`);
85
+ }
86
+ const flags = {};
87
+ for (const pair of pairs) {
88
+ const separator = pair.indexOf("=");
89
+ if (separator < 0) {
90
+ throw new FeatureFlagSpecError(`${where} expects KEY=VALUE, got: ${pair}.`);
91
+ }
92
+ const key = pair.slice(0, separator).trim();
93
+ assertKey(key, where);
94
+ if (Object.prototype.hasOwnProperty.call(flags, key)) {
95
+ throw new FeatureFlagSpecError(`${where} sets ${key} more than once; give each key one value.`);
96
+ }
97
+ flags[key] = normalizeFeatureFlagValue(pair.slice(separator + 1), where);
98
+ }
99
+ return flags;
100
+ }
101
+ function normalizeFeatureFlagRecord(raw, where) {
102
+ if (raw === void 0 || raw === null) return {};
103
+ if (typeof raw !== "object" || Array.isArray(raw)) {
104
+ throw new FeatureFlagSpecError(
105
+ `${where} remoteFeatureFlags must be an object of key \u2192 variant name, true/false, or JSON.`,
106
+ 'edit the wallet fixture so remoteFeatureFlags is an object, e.g. { "perpsTAT3938AbtestScreenVsBottomSheet": "treatment" }'
107
+ );
108
+ }
109
+ const flags = {};
110
+ for (const [key, value] of Object.entries(raw)) {
111
+ assertKey(key, `${where} remoteFeatureFlags`);
112
+ flags[key] = normalizeFeatureFlagValue(value, `${where} remoteFeatureFlags.${key}`);
113
+ }
114
+ return flags;
115
+ }
116
+ function describeFeatureFlagValue(value) {
117
+ if (typeof value === "boolean" || typeof value === "number") return String(value);
118
+ if (typeof value === "string") return value;
119
+ if (value && typeof value === "object" && !Array.isArray(value)) {
120
+ const named = value["name"];
121
+ if (typeof named === "string") return named;
122
+ }
123
+ return JSON.stringify(value);
124
+ }
125
+ function featureFlagReport(overrides, detail = {}) {
126
+ return {
127
+ summary: formatFeatureFlagLine(overrides, detail),
128
+ overrides,
129
+ overrideCount: overrides ? Object.keys(overrides).length : null,
130
+ ...detail.error ? { error: detail.error } : {},
131
+ ...detail.sourcePath ? { sourcePath: detail.sourcePath } : {}
132
+ };
133
+ }
134
+ function formatFeatureFlagLine(overrides, detail = {}) {
135
+ if (detail.error) {
136
+ return `feature flags: (unreadable${detail.sourcePath ? ` \u2014 ${detail.sourcePath}` : ""}: ${detail.error})`;
137
+ }
138
+ if (!overrides) return "feature flags: (no runtime)";
139
+ const entries = Object.entries(overrides);
140
+ if (entries.length === 0) return "feature flags: 0 override(s)";
141
+ const described = entries.map(([key, value]) => `${key}=${describeFeatureFlagValue(value)}`).join(", ");
142
+ return `feature flags: ${entries.length} override(s) ${described}`;
143
+ }
144
+ function sameFeatureFlags(a, b) {
145
+ const keys = [.../* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)])];
146
+ return keys.every((key) => JSON.stringify(a[key]) === JSON.stringify(b[key]));
147
+ }
148
+ function fixtureFeatureFlags(fixturePath) {
149
+ let raw;
150
+ try {
151
+ raw = readFileSync(fixturePath, "utf8");
152
+ } catch {
153
+ return {};
154
+ }
155
+ let body;
156
+ try {
157
+ body = JSON.parse(raw);
158
+ } catch (error) {
159
+ throw new FeatureFlagSpecError(
160
+ `${fixturePath} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
161
+ "fix the wallet fixture, or run mm-harness fixtures init to regenerate it"
162
+ );
163
+ }
164
+ return normalizeFeatureFlagRecord(body?.["remoteFeatureFlags"], fixturePath);
165
+ }
166
+ function remoteFlagRequestFor(flags) {
167
+ if (Object.keys(flags).length === 0) return void 0;
168
+ return { flags, json: JSON.stringify(flags), spec: toRemoteFlagSpec(flags) };
169
+ }
170
+ function toRemoteFlagSpec(flags) {
171
+ return Object.entries(flags).map(([key, value]) => {
172
+ if (typeof value === "boolean") return `${key}=${String(value)}`;
173
+ if (value && typeof value === "object" && !Array.isArray(value)) {
174
+ const named = value["name"];
175
+ if (typeof named === "string" && Object.keys(value).length === 1) return `${key}=${named}`;
176
+ }
177
+ return `${key}=${JSON.stringify(value)}`;
178
+ }).join(",");
179
+ }
180
+ export {
181
+ FeatureFlagSpecError,
182
+ REMOTE_FLAG_USAGE,
183
+ describeFeatureFlagValue,
184
+ featureFlagReport,
185
+ fixtureFeatureFlags,
186
+ formatFeatureFlagLine,
187
+ normalizeFeatureFlagRecord,
188
+ normalizeFeatureFlagValue,
189
+ parseRemoteFlagSpec,
190
+ remoteFlagRequestFor,
191
+ sameFeatureFlags,
192
+ toRemoteFlagSpec
193
+ };
package/dist/harness.js CHANGED
@@ -259,19 +259,7 @@ Next: mm-harness install --platform core --target ${target}`
259
259
  }
260
260
  return { command: delegate, prefixArgs: ["doctor", "--adapter", "core"] };
261
261
  }
262
- if (adapter === "mobile" && action === "verify") {
263
- return { inProcess: "mobile-verify" };
264
- }
265
- const installedScript = path.join(recipeHarnessPath(target, adapter), "scripts", `${action}.sh`);
266
- if (fs.existsSync(installedScript)) {
267
- return { command: installedScript, prefixArgs: [] };
268
- }
269
- const command = resolveEntry(
270
- runnerDir,
271
- [`adapters/${adapter}/${action}.sh`, `scripts/${adapter}/${action}.sh`],
272
- "file"
273
- );
274
- return { command, prefixArgs: [] };
262
+ return { inProcess: adapter === "mobile" ? "mobile-verify" : "extension-verify" };
275
263
  }
276
264
  async function handleHarness(argv) {
277
265
  const action = argv[0];
@@ -340,10 +328,10 @@ async function handleHarness(argv) {
340
328
  }
341
329
  const start = Date.now();
342
330
  if ("inProcess" in dispatch) {
343
- const { runMobileVerify } = await import("./adapters/mobile/verify.js");
331
+ const runVerify = dispatch.inProcess === "mobile-verify" ? (await import("./adapters/mobile/verify.js")).runMobileVerify : (await import("./adapters/extension/verify.js")).runExtensionVerify;
344
332
  let exitCode;
345
333
  try {
346
- exitCode = await runMobileVerify(forwardArgs, {
334
+ exitCode = await runVerify(forwardArgs, {
347
335
  out: quiet ? () => {
348
336
  } : (line) => {
349
337
  (json ? process.stderr : process.stdout).write(`${line}
@@ -435,7 +435,13 @@ Example:
435
435
  Example (real actions; run mm-harness actions for this checkout's full set):
436
436
  mm-harness call ensure_unlocked --adapter extension # a wallet action (extension/mobile)
437
437
  mm-harness call navigate page=perps --adapter mobile
438
- mm-harness call command cmd="echo hi" --adapter core # the universal action (all adapters)`
438
+ mm-harness call command cmd="echo hi" --adapter core # the universal action (all adapters)
439
+
440
+ Remote feature flags (mobile applies at runtime; extension pins at launch):
441
+ mm-harness call metamask.feature_flags.read --adapter mobile
442
+ mm-harness call metamask.feature_flags.set --adapter mobile \\
443
+ --arg 'flags={"perpsTAT3938AbtestScreenVsBottomSheet":"treatment"}'
444
+ mm-harness call metamask.feature_flags.clear --adapter mobile`
439
445
  },
440
446
  {
441
447
  name: "run",
@@ -691,6 +697,11 @@ Example:
691
697
  --surface <fullscreen|sidepanel> Extension display mode (default: fullscreen)
692
698
  --sidepanel | --fullscreen Legacy aliases for --surface
693
699
  --url <dapp-url> Open a dapp beside the sidepanel (default: MetaMask test dapp)
700
+ --remote-flag <KEY=VALUE[,...]> Pin remote feature flags. VALUE is a variant name (treatment),
701
+ true/false, or JSON. Mobile applies it through
702
+ RemoteFeatureFlagController once the bridge is ready and fails the
703
+ launch if it does not read back; Extension pins it into the per-run
704
+ dist snapshot manifest (so it skips the reuse fast path).
694
705
  --heal <off|infra-only|auto> Healing policy (default auto); bounds always enforced
695
706
  --device <udid|name> Target simulator/device (env: IOS_SIMULATOR / ADB_SERIAL)
696
707
  --cdp-port <port> Extension CDP port (env: CDP_PORT / RECIPE_CDP_PORT)
@@ -704,7 +715,8 @@ Example:
704
715
  mm-harness launch ios
705
716
  mm-harness launch android --clear-metro --verify
706
717
  mm-harness launch ios --build
707
- mm-harness launch --verify --json`
718
+ mm-harness launch --verify --json
719
+ mm-harness launch --remote-flag perpsTAT3938AbtestScreenVsBottomSheet=treatment`
708
720
  },
709
721
  {
710
722
  name: "logs",
@@ -849,6 +861,7 @@ Example:
849
861
  --extension-dir <path> finalize: loaded extension dist (e.g. dist/chrome)
850
862
  --extension-id-file <path> finalize: optional file to read/write the resolved extension id
851
863
  --action-manifest <path> Extension set: override the wallet action manifest
864
+ --remote-flag <KEY=VALUE[,...]> set/reset: one-run override layered over the fixture's remoteFeatureFlags
852
865
  --adapter <mobile|extension> Target adapter (auto-detected inside a checkout)
853
866
  --target <path> Checkout path (default: cwd)
854
867
  --device <udid|serial|name> Mobile only: target this device for sync/set
@@ -0,0 +1,29 @@
1
+ // Core is the headless adapter: it drives controllers over HTTP and runs no app
2
+ // runtime, so there is no RemoteFeatureFlagController and no manifest to pin.
3
+ // Reads answer `{ supported: false }` with exit 0 (an honest empty observation);
4
+ // mutations exit non-zero so nobody records a variant that was never applied.
5
+ import { readFile, writeFile } from 'node:fs/promises';
6
+
7
+ export const CORE_UNSUPPORTED_REASON =
8
+ 'core is headless: it has no app runtime, so it holds no RemoteFeatureFlagController and no extension manifest to pin. Run this action against the mobile or extension adapter.';
9
+
10
+ export async function runCoreFeatureFlagStub({ mutation }) {
11
+ const inputPath = process.argv[2] || process.env.METAMASK_RECIPE_ADAPTER_INPUT;
12
+ if (!inputPath) throw new Error('Adapter input path is required.');
13
+ const input = JSON.parse(await readFile(inputPath, 'utf8'));
14
+ const output = {
15
+ action: input.action,
16
+ source: 'core-headless',
17
+ supported: false,
18
+ reason: CORE_UNSUPPORTED_REASON,
19
+ effective: {},
20
+ overrides: {},
21
+ next: `mm-harness call ${input.action} --adapter mobile --target <metamask-mobile-checkout>`,
22
+ proofPath: 'core-feature-flags-unsupported',
23
+ };
24
+ if (input.outputPath) await writeFile(input.outputPath, `${JSON.stringify(output, null, 2)}\n`);
25
+ else process.stdout.write(`${JSON.stringify(output)}\n`);
26
+ if (mutation) {
27
+ throw new Error(`${input.action} is not supported on core. ${CORE_UNSUPPORTED_REASON}\n Next: ${output.next}`);
28
+ }
29
+ }
@@ -0,0 +1,8 @@
1
+ // core has no runtime feature flags — answer honestly instead of fabricating one.
2
+ import { pathToFileURL } from 'node:url';
3
+
4
+ import { runCoreFeatureFlagStub } from './_unsupported.mjs';
5
+
6
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
7
+ await runCoreFeatureFlagStub({ mutation: true });
8
+ }
@@ -0,0 +1,8 @@
1
+ // core has no runtime feature flags — answer honestly instead of fabricating one.
2
+ import { pathToFileURL } from 'node:url';
3
+
4
+ import { runCoreFeatureFlagStub } from './_unsupported.mjs';
5
+
6
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
7
+ await runCoreFeatureFlagStub({ mutation: false });
8
+ }
@@ -0,0 +1,8 @@
1
+ // core has no runtime feature flags — answer honestly instead of fabricating one.
2
+ import { pathToFileURL } from 'node:url';
3
+
4
+ import { runCoreFeatureFlagStub } from './_unsupported.mjs';
5
+
6
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
7
+ await runCoreFeatureFlagStub({ mutation: true });
8
+ }
@@ -0,0 +1,48 @@
1
+ // Clear overrides when there are none; otherwise name the relaunch that drops the pin.
2
+ //
3
+ // The extension's overrides live in the per-run dist snapshot manifest, which is
4
+ // re-created at launch. A slot with no pin is already clear (exit 0); a pinned
5
+ // slot needs a relaunch without --remote-flag, so this refuses rather than
6
+ // reporting a clear it did not perform.
7
+ import { runAdapter, withExtensionPage, writeOutput } from '../platform/cdp.mjs';
8
+ import { EXTENSION_SET_UNSUPPORTED_REASON, readFeatureFlagsExpression, resolveOverrides } from './state.mjs';
9
+
10
+ runAdapter(async (input) => {
11
+ const state = await withExtensionPage(input, (page) =>
12
+ page.evaluate(readFeatureFlagsExpression(), { awaitPromise: true }));
13
+ const resolved = resolveOverrides(state, input.context?.projectRoot);
14
+ const effective = { ...state.stateFlags, ...resolved.overrides };
15
+ const pinned = Object.keys(resolved.overrides);
16
+ const target = String(input.context?.projectRoot ?? '<extension-checkout>');
17
+ const quoted = /[^A-Za-z0-9_@%+=:,./-]/u.test(target) ? `'${target.replaceAll("'", "'\\''")}'` : target;
18
+ const next = `mm-harness launch --adapter extension --target ${quoted}`;
19
+ if (pinned.length === 0) {
20
+ return {
21
+ action: input.action,
22
+ source: 'extension-manifest-pin',
23
+ supported: true,
24
+ cleared: [],
25
+ overrides: {},
26
+ effective,
27
+ overrideSource: resolved.overrideSource,
28
+ needsRelaunch: false,
29
+ proofPath: 'extension-remote-feature-flags',
30
+ };
31
+ }
32
+ await writeOutput(input, {
33
+ action: input.action,
34
+ source: 'extension-manifest-pin',
35
+ supported: false,
36
+ reason: EXTENSION_SET_UNSUPPORTED_REASON,
37
+ cleared: [],
38
+ overrides: resolved.overrides,
39
+ effective,
40
+ overrideSource: resolved.overrideSource,
41
+ needsRelaunch: true,
42
+ next,
43
+ proofPath: 'extension-remote-feature-flags',
44
+ });
45
+ throw new Error(
46
+ `metamask.feature_flags.clear cannot drop the extension manifest pin at runtime (${pinned.join(', ')}). ${EXTENSION_SET_UNSUPPORTED_REASON}\n Next: ${next}`,
47
+ );
48
+ });
@@ -0,0 +1,27 @@
1
+ // Read the effective remote feature flags and the manifest pin from the running extension.
2
+ import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
3
+ import { readFeatureFlagsExpression, resolveOverrides, selectKeys } from './state.mjs';
4
+
5
+ runAdapter((input) => withExtensionPage(input, async (page) => {
6
+ const keys = Array.isArray(input.node?.keys) ? input.node.keys.map(String) : [];
7
+ const state = await page.evaluate(readFeatureFlagsExpression(), { awaitPromise: true });
8
+ const resolved = resolveOverrides(state, input.context?.projectRoot);
9
+ // The extension merges the manifest pin over the fetched flags, manifest wins.
10
+ const effective = { ...state.stateFlags, ...resolved.overrides };
11
+ return {
12
+ action: input.action,
13
+ source: 'extension-manifest-pin-and-controller-state',
14
+ overrideSource: resolved.overrideSource,
15
+ ...(resolved.overrideSourcePath ? { overrideSourcePath: resolved.overrideSourcePath } : {}),
16
+ ...(resolved.manifestUnreadableReason
17
+ ? { manifestUnreadableReason: resolved.manifestUnreadableReason }
18
+ : {}),
19
+ effective: selectKeys(effective, keys),
20
+ overrides: selectKeys(resolved.overrides, keys),
21
+ overrideCount: Object.keys(resolved.overrides).length,
22
+ fetched: selectKeys(state.stateFlags, keys),
23
+ thresholdGroups: state.thresholdGroups,
24
+ supported: true,
25
+ proofPath: 'extension-remote-feature-flags',
26
+ };
27
+ }));
@@ -0,0 +1,36 @@
1
+ // Refuse a runtime override honestly and name the relaunch that pins the flag.
2
+ //
3
+ // The extension's only override channel is the per-run dist snapshot manifest
4
+ // (`_flags.remoteFeatureFlags`), written at launch. Nothing in the background
5
+ // exposes setFlagOverride, so there is no code path that could make this action
6
+ // take effect — see EXTENSION_SET_UNSUPPORTED_REASON in ./state.mjs. The
7
+ // structured refusal is written to the output file before throwing so callers
8
+ // keep the record and still see a non-zero exit.
9
+ import { normalizeFeatureFlagRecord, toRemoteFlagSpec } from '../../harness-exports.mjs';
10
+ import { runAdapter, writeOutput } from '../platform/cdp.mjs';
11
+ import { EXTENSION_SET_UNSUPPORTED_REASON, relaunchHint } from './state.mjs';
12
+
13
+ runAdapter(async (input) => {
14
+ const requested = normalizeFeatureFlagRecord(input.node?.flags, 'metamask.feature_flags.set');
15
+ if (Object.keys(requested).length === 0) {
16
+ throw new Error('metamask.feature_flags.set requires a non-empty flags object, e.g. flags={"perpsTAT3938AbtestScreenVsBottomSheet":"treatment"}.');
17
+ }
18
+ const next = relaunchHint(
19
+ String(input.context?.projectRoot ?? '<extension-checkout>'),
20
+ toRemoteFlagSpec(requested),
21
+ );
22
+ await writeOutput(input, {
23
+ action: input.action,
24
+ source: 'extension-manifest-pin',
25
+ supported: false,
26
+ reason: EXTENSION_SET_UNSUPPORTED_REASON,
27
+ applied: [],
28
+ requested,
29
+ needsRelaunch: true,
30
+ next,
31
+ proofPath: 'extension-remote-feature-flags',
32
+ });
33
+ throw new Error(
34
+ `metamask.feature_flags.set is not supported on the extension at runtime. ${EXTENSION_SET_UNSUPPORTED_REASON}\n Next: ${next}`,
35
+ );
36
+ });
@@ -0,0 +1,129 @@
1
+ // Extension remote feature-flag transport: the manifest `_flags` pin plus controller state.
2
+ //
3
+ // The extension resolves flags as
4
+ // getRemoteFeatureFlags = merge({}, state.metamask.remoteFeatureFlags, manifest._flags.remoteFeatureFlags)
5
+ // (shared/lib/selectors/remote-feature-flags.ts), with the manifest winning. The
6
+ // manifest pin is therefore the extension's override channel, and it is written
7
+ // into the per-run dist snapshot at launch by adapters/extension/pin-remote-flags.cjs.
8
+ //
9
+ // There is no runtime override RPC: metamask-controller.js never wires
10
+ // RemoteFeatureFlagController.setFlagOverride into getApi(), and
11
+ // `grep -rn 'setFlagOverride' app/ shared/ ui/` finds nothing in the checkout,
12
+ // so stateHooks.submitRequestToBackground cannot reach it. `set` and `clear`
13
+ // therefore refuse honestly and name the relaunch that does work.
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ import { recipeRuntimeDir } from '../../harness-exports.mjs';
19
+
20
+ export const EXTENSION_SET_UNSUPPORTED_REASON =
21
+ 'The extension exposes no runtime remote feature-flag override RPC: RemoteFeatureFlagController.setFlagOverride is never registered in metamask-controller.js getApi(), so stateHooks.submitRequestToBackground cannot reach it. Overrides are pinned into the per-run dist snapshot manifest at launch.';
22
+
23
+ /**
24
+ * Page expression reading the controller's fetched flags, plus the running
25
+ * manifest's pin when the page realm can still reach `chrome`.
26
+ *
27
+ * A LavaMoat build scuttles `chrome` in the page realm, so the manifest read is
28
+ * best-effort here and the caller falls back to the snapshot file on disk. The
29
+ * failure is reported, never swallowed: `manifestReadable` says which source the
30
+ * override record can come from.
31
+ */
32
+ export function readFeatureFlagsExpression() {
33
+ return `(async () => {
34
+ const hooks = globalThis.stateHooks || {};
35
+ let root = null;
36
+ if (typeof hooks.getCleanAppState === 'function') {
37
+ root = await hooks.getCleanAppState();
38
+ }
39
+ if ((!root || typeof root !== 'object') && typeof hooks.store?.getState === 'function') {
40
+ root = hooks.store.getState();
41
+ }
42
+ if (!root || typeof root !== 'object') {
43
+ throw new Error('stateHooks.getCleanAppState and stateHooks.store.getState are unavailable.');
44
+ }
45
+ const metamask = root.metamask || root;
46
+ let manifestFlags = null;
47
+ let manifestUnreadableReason = null;
48
+ try {
49
+ // Gate on the accessor itself: optional chaining alone would yield
50
+ // undefined on a page without chrome and report an empty pin as if the
51
+ // running manifest had answered.
52
+ if (typeof globalThis.chrome?.runtime?.getManifest !== 'function') {
53
+ throw new Error('chrome.runtime.getManifest is not a function on this page.');
54
+ }
55
+ const manifest = globalThis.chrome.runtime.getManifest();
56
+ manifestFlags = manifest?._flags?.remoteFeatureFlags || {};
57
+ } catch (error) {
58
+ // Not swallowed: a LavaMoat build scuttles chrome in the page realm, and
59
+ // the caller answers from the snapshot manifest on disk instead. The
60
+ // reason travels with the result so the source is never ambiguous.
61
+ manifestUnreadableReason = String(error?.message || error);
62
+ }
63
+ return {
64
+ manifestReadable: manifestFlags !== null,
65
+ manifestUnreadableReason,
66
+ stateFlags: metamask.remoteFeatureFlags || {},
67
+ manifestFlags: manifestFlags || {},
68
+ thresholdGroups: metamask.featureFlagThresholdGroups || {},
69
+ };
70
+ })()`;
71
+ }
72
+
73
+ /**
74
+ * The per-run dist snapshot manifest, which is what pin-remote-flags.cjs writes
75
+ * and what the browser was launched from. `null` means no snapshot exists.
76
+ */
77
+ export function readSnapshotManifestFlags(projectRoot) {
78
+ const manifestPath = path.join(
79
+ String(projectRoot ?? ''),
80
+ recipeRuntimeDir(),
81
+ process.env.RECIPE_RUNTIME_DIST_DIR || 'runtime-dist',
82
+ 'manifest.json',
83
+ );
84
+ if (!fs.existsSync(manifestPath)) return null;
85
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
86
+ return { flags: manifest?._flags?.remoteFeatureFlags || {}, path: manifestPath };
87
+ }
88
+
89
+ /**
90
+ * Resolve the override record from the running manifest when the page can read
91
+ * it, and from the launched snapshot on disk when LavaMoat scuttles `chrome`.
92
+ * Always reports which source answered.
93
+ */
94
+ export function resolveOverrides(pageState, projectRoot) {
95
+ if (pageState.manifestReadable) {
96
+ return {
97
+ overrides: pageState.manifestFlags,
98
+ overrideSource: 'running-extension-manifest',
99
+ };
100
+ }
101
+ const snapshot = readSnapshotManifestFlags(projectRoot);
102
+ if (!snapshot) {
103
+ throw new Error(
104
+ `The page realm cannot read chrome.runtime.getManifest() (${pageState.manifestUnreadableReason}) and no dist snapshot manifest exists under ${String(projectRoot)}, so the pinned remote feature flags are unknown.`,
105
+ );
106
+ }
107
+ return {
108
+ overrides: snapshot.flags,
109
+ overrideSource: 'launched-dist-snapshot-manifest',
110
+ overrideSourcePath: snapshot.path,
111
+ manifestUnreadableReason: pageState.manifestUnreadableReason,
112
+ };
113
+ }
114
+
115
+ /** Restrict a flag map to the requested keys, when `keys` is given. */
116
+ export function selectKeys(map, keys) {
117
+ if (!Array.isArray(keys) || keys.length === 0) return map;
118
+ const selected = {};
119
+ // An absent key reads as null rather than disappearing: a caller that asked
120
+ // about a key deserves an answer about it, and JSON drops undefined.
121
+ for (const key of keys) selected[key] = map[key] === undefined ? null : map[key];
122
+ return selected;
123
+ }
124
+
125
+ /** The relaunch that does pin a flag, quoted so it can be run as printed. */
126
+ export function relaunchHint(target, spec) {
127
+ const quoted = /[^A-Za-z0-9_@%+=:,./-]/u.test(target) ? `'${target.replaceAll("'", "'\\''")}'` : target;
128
+ return `mm-harness launch --adapter extension --remote-flag ${spec} --target ${quoted}`;
129
+ }
@@ -15,13 +15,27 @@ async function load(distRelative, srcRelative) {
15
15
 
16
16
  const paths = await load('dist/paths.js', 'src/paths.ts');
17
17
  const recording = await load('dist/run-recording.js', 'src/run-recording.ts');
18
+ const featureFlags = await load('dist/feature-flags.js', 'src/feature-flags.ts');
18
19
 
19
20
  export const {
20
21
  importRecipeHarnessRuntimeBrowserExtension,
21
22
  importRecipeHarnessRuntimeCdp,
22
23
  extensionIdPath,
23
24
  recipeHarnessPath,
25
+ recipeRuntimeDir,
24
26
  walletFixturePath,
25
27
  } = paths;
26
28
 
27
29
  export const { captureActiveRecipeRecordingSnapshot } = recording;
30
+
31
+ // One normalisation for --remote-flag specs, fixture objects, and the
32
+ // doctor/status summary line. See src/feature-flags.ts.
33
+ export const {
34
+ FeatureFlagSpecError,
35
+ describeFeatureFlagValue,
36
+ formatFeatureFlagLine,
37
+ normalizeFeatureFlagRecord,
38
+ normalizeFeatureFlagValue,
39
+ parseRemoteFlagSpec,
40
+ toRemoteFlagSpec,
41
+ } = featureFlags;
@@ -0,0 +1,20 @@
1
+ // Drop every local remote feature-flag override so the slot leaks no variant.
2
+ import { runAdapter } from '../platform/bridge.mjs';
3
+ import { clearMobileFlags } from './controller.mjs';
4
+
5
+ runAdapter(async (input) => {
6
+ const { cleared, effective, overrides } = await clearMobileFlags(input);
7
+ const remaining = Object.keys(overrides);
8
+ if (remaining.length > 0) {
9
+ throw new Error(`metamask.feature_flags.clear left ${remaining.length} override(s): ${remaining.join(', ')}.`);
10
+ }
11
+ return {
12
+ action: input.action,
13
+ source: 'mobile-remote-feature-flag-controller',
14
+ cleared,
15
+ overrides,
16
+ effective,
17
+ supported: true,
18
+ proofPath: 'mobile-remote-feature-flags',
19
+ };
20
+ });