@deeeed/metamask-harness 0.59.0 → 0.61.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 (47) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +48 -0
  3. package/adapters/extension/build-handoff.cjs +4 -0
  4. package/adapters/extension/live.sh +15 -11
  5. package/adapters/extension/pin-remote-flags.cjs +69 -30
  6. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +16 -0
  7. package/adapters/mobile/bridge-runtime/setup-wallet.sh +69 -2
  8. package/dist/adapters/extension/feature-flags.js +41 -0
  9. package/dist/adapters/extension/runtime-decision.js +1 -0
  10. package/dist/adapters/mobile/feature-flags.js +127 -0
  11. package/dist/adapters.js +6 -1
  12. package/dist/command-contract.js +6 -3
  13. package/dist/commands/doctor.js +9 -1
  14. package/dist/commands/fixtures.js +45 -3
  15. package/dist/commands/launch/extension.js +28 -6
  16. package/dist/commands/launch/index.js +128 -17
  17. package/dist/commands/launch/mobile.js +26 -2
  18. package/dist/commands/mobile-device-view.js +8 -1
  19. package/dist/commands/prepare.js +20 -3
  20. package/dist/commands/run-engine.js +10 -1
  21. package/dist/commands/runtime-launch.js +10 -1
  22. package/dist/commands/status-probe.js +14 -1
  23. package/dist/commands/status-watch.js +32 -0
  24. package/dist/commands/status.js +13 -0
  25. package/dist/doctor.js +9 -1
  26. package/dist/feature-flags.js +193 -0
  27. package/dist/mm-harness-cli.js +15 -2
  28. package/dist/task-view.js +34 -1
  29. package/library/actions/core/feature_flags/_unsupported.mjs +29 -0
  30. package/library/actions/core/feature_flags/clear.mjs +8 -0
  31. package/library/actions/core/feature_flags/read.mjs +8 -0
  32. package/library/actions/core/feature_flags/set.mjs +8 -0
  33. package/library/actions/extension/feature_flags/clear.mjs +48 -0
  34. package/library/actions/extension/feature_flags/read.mjs +27 -0
  35. package/library/actions/extension/feature_flags/set.mjs +36 -0
  36. package/library/actions/extension/feature_flags/state.mjs +129 -0
  37. package/library/actions/harness-exports.mjs +14 -0
  38. package/library/actions/mobile/feature_flags/clear.mjs +20 -0
  39. package/library/actions/mobile/feature_flags/controller.mjs +120 -0
  40. package/library/actions/mobile/feature_flags/read.mjs +20 -0
  41. package/library/actions/mobile/feature_flags/set.mjs +25 -0
  42. package/library/actions/mobile/platform/bridge.mjs +2 -2
  43. package/library/actions/mobile/wallet/reset.mjs +22 -1
  44. package/library/manifests/core.action-manifest.json +90 -0
  45. package/library/manifests/extension.action-manifest.json +70 -0
  46. package/library/manifests/mobile.action-manifest.json +90 -0
  47. package/package.json +5 -5
@@ -20,6 +20,7 @@ import {
20
20
  targetPath,
21
21
  usageError
22
22
  } from "./parse-args.js";
23
+ import { FeatureFlagSpecError, parseRemoteFlagSpec } from "../feature-flags.js";
23
24
  async function handleRuntimeLaunch({ options }) {
24
25
  if (optionFlag(options, "help")) {
25
26
  console.log(`mm-harness runtime-launch --adapter <extension|mobile> --target <checkout> [flags]
@@ -129,7 +130,15 @@ async function handleRuntimeLaunch({ options }) {
129
130
  if (chromeUserDataDir) command.push("--chrome-user-data-dir", chromeUserDataDir);
130
131
  command.push(optionFlag(options, "resetProfile") ? "--reset-profile" : "--preserve-profile");
131
132
  const remoteFlag = optionString(options, "remoteFlag");
132
- if (remoteFlag) command.push("--remote-flag", remoteFlag);
133
+ if (remoteFlag) {
134
+ try {
135
+ command.push("--remote-flags-json", JSON.stringify(parseRemoteFlagSpec(remoteFlag)));
136
+ } catch (error) {
137
+ if (!(error instanceof FeatureFlagSpecError)) throw error;
138
+ throw usageError(`${error.message}
139
+ Next: ${error.userAction}`);
140
+ }
141
+ }
133
142
  if (!fs.existsSync(liveScript)) {
134
143
  const report2 = runtimeLaunchReport("fail", {
135
144
  adapter,
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fixtureSummary } from "../doctor.js";
5
+ import { normalizeFeatureFlagRecord } from "../feature-flags.js";
5
6
  import { runnerDir } from "../paths.js";
6
7
  const PROBE_TIMEOUT_MS = 2e3;
7
8
  function bridgeScriptPath() {
@@ -11,6 +12,15 @@ function truncateAddress(address) {
11
12
  if (!address || address.length <= 10) return address;
12
13
  return `${address.slice(0, 6)}\u2026${address.slice(-4)}`;
13
14
  }
15
+ function entryFeatureFlagOverrides(entry) {
16
+ const flags = entry.featureFlags;
17
+ if (!flags || typeof flags !== "object") return void 0;
18
+ try {
19
+ return normalizeFeatureFlagRecord(flags.overrides ?? {}, "RemoteFeatureFlagController.localOverrides");
20
+ } catch {
21
+ return void 0;
22
+ }
23
+ }
14
24
  function routeToString(route) {
15
25
  if (typeof route === "string") return route;
16
26
  if (route && typeof route === "object" && "name" in route) {
@@ -38,7 +48,8 @@ function parseBridgeEntries(raw) {
38
48
  platform: typeof e.platform === "string" ? e.platform : "",
39
49
  // agenticPresent is only set when the bridge explicitly sends it (false = absent).
40
50
  // An omitted value remains unknown.
41
- ...typeof e.agenticPresent === "boolean" ? { agenticPresent: e.agenticPresent } : {}
51
+ ...typeof e.agenticPresent === "boolean" ? { agenticPresent: e.agenticPresent } : {},
52
+ ...e.featureFlags !== void 0 ? { featureFlags: e.featureFlags } : {}
42
53
  }));
43
54
  }
44
55
  function matchBridgeEntry(device, entries, devices) {
@@ -160,9 +171,11 @@ async function probeMobileLiveState(target, devices) {
160
171
  const currentScreen = routeToString(entry.route);
161
172
  const addrRaw = typeof account?.address === "string" ? account.address : void 0;
162
173
  const labelRaw = typeof account?.name === "string" ? account.name : void 0;
174
+ const overrides = entryFeatureFlagOverrides(entry);
163
175
  const liveEntry = {
164
176
  fixtureStatus,
165
177
  walletState,
178
+ ...overrides !== void 0 ? { featureFlagOverrides: overrides } : {},
166
179
  ...currentScreen !== void 0 ? { currentScreen } : {},
167
180
  ...labelRaw !== void 0 && addrRaw !== void 0 ? { selectedAccount: { label: labelRaw, address: truncateAddress(addrRaw) } } : {}
168
181
  };
@@ -130,6 +130,9 @@ function renderTaskView(view, title, explicitTask = false) {
130
130
  }
131
131
  phase = row.phase;
132
132
  lines.push(renderRow(row, view.currentStep, out));
133
+ for (const unit of view.subtasks.filter((entry) => entry.parentStep === row.number)) {
134
+ lines.push(...renderSubtask(unit, out));
135
+ }
133
136
  }
134
137
  const signal = view.signal;
135
138
  if (signal) {
@@ -171,6 +174,21 @@ function changedLines(previous, next) {
171
174
  if (!row.checked || before.get(row.number) === true) continue;
172
175
  lines.push(`checked ${row.number} ${row.label}${row.durationMs !== void 0 ? ` (${formatDuration(row.durationMs)})` : ""}`);
173
176
  }
177
+ const beforeUnits = new Map(previous.subtasks.map((unit) => [unit.id, unit]));
178
+ for (const unit of next.subtasks) {
179
+ const before2 = beforeUnits.get(unit.id);
180
+ if (!before2) {
181
+ lines.push(`subtask ${unit.id} registered on step ${unit.parentStep} (${unit.rows.length} rows)`);
182
+ }
183
+ const beforeRows = new Map((before2?.rows ?? []).map((row) => [row.number, row.checked]));
184
+ for (const row of unit.rows) {
185
+ if (!row.checked || beforeRows.get(row.number) === true) continue;
186
+ lines.push(`subtask ${unit.id} checked ${row.number} ${row.label}`);
187
+ }
188
+ if (before2 && before2.status !== unit.status) {
189
+ lines.push(`subtask ${unit.id} ${unit.status ?? "unknown"}`);
190
+ }
191
+ }
174
192
  if (previous.signal?.step !== next.signal?.step && next.signal?.step) {
175
193
  lines.push(`step ${stepLabel(next.signal.step)}`);
176
194
  }
@@ -198,6 +216,20 @@ function renderRow(row, currentStep, out) {
198
216
  const text = row.checked ? out("dim", label) : out("bold", label);
199
217
  return ` ${marker} ${out("dim", number)} ${text}${timing ? ` ${out("dim", timing)}` : ""}`;
200
218
  }
219
+ function renderSubtask(unit, out) {
220
+ const done = unit.rows.filter((row) => row.checked).length;
221
+ const header = joinParts([
222
+ `${out("label", "subtask")} ${out("accent", unit.id)}`,
223
+ statusWord(out, unit.status),
224
+ `${done}/${unit.rows.length}`,
225
+ unit.source?.ref && out("dim", unit.source.ref)
226
+ ]);
227
+ const current = unit.rows.find((row) => !row.checked);
228
+ return [
229
+ ` ${header}`,
230
+ ...unit.rows.map((row) => ` ${renderRow(row, current?.number, out)}`)
231
+ ];
232
+ }
201
233
  function libraryLabel(library) {
202
234
  const home = os.homedir();
203
235
  const location = home && library.path.startsWith(`${home}/`) ? `~${library.path.slice(home.length)}` : library.path;
@@ -1,5 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { readExtensionPinnedFlags } from "../adapters/extension/feature-flags.js";
4
+ import { featureFlagReport } from "../feature-flags.js";
3
5
  import { color } from "../cli-color.js";
4
6
  import { collectCheckoutView, renderCheckoutView } from "../checkout-view.js";
5
7
  import { detectAdapter } from "../harness.js";
@@ -49,6 +51,8 @@ async function handleStatus({ options }) {
49
51
  }
50
52
  }
51
53
  const next = surface.hints.relaunch;
54
+ const extensionFlags = adapter === "extension" ? readExtensionPinnedFlags(target) : null;
55
+ const staticFeatureFlags = extensionFlags ? featureFlagReport(extensionFlags.overrides, extensionFlags) : featureFlagReport(null);
52
56
  const deviceView = adapter === "mobile" ? mobileDeviceView(allDevices) : null;
53
57
  const devices = deviceView?.devices ?? [];
54
58
  const discovery = deviceView?.deviceDiscoveryErrors.length ? { deviceDiscoveryErrors: deviceView.deviceDiscoveryErrors } : {};
@@ -66,6 +70,7 @@ async function handleStatus({ options }) {
66
70
  target,
67
71
  view,
68
72
  devices: liveView.devicesWithLive,
73
+ featureFlags: featureFlagReport(liveView.featureFlagOverrides),
69
74
  ...discovery,
70
75
  ...liveView.additionalReachableDevices.length > 0 ? { additionalReachableDevices: liveView.additionalReachableDevices } : {},
71
76
  ...liveView.androidPortHints.length > 0 ? { androidPortHints: liveView.androidPortHints } : {},
@@ -86,6 +91,7 @@ async function handleStatus({ options }) {
86
91
  target,
87
92
  view,
88
93
  devices,
94
+ featureFlags: staticFeatureFlags,
89
95
  ...discovery,
90
96
  ...runtime ? { runtime } : {},
91
97
  next: discoveryNext ?? (runtime ? nextForRuntime(surface.hints.launch, surface.hints.relaunch, runtime) : next)
@@ -100,10 +106,12 @@ async function handleStatus({ options }) {
100
106
  const out = (style, text) => color(style, text, { stream: process.stdout });
101
107
  console.log(`${out("label", "status")} ${out("bold", adapter)} ${out("dim", target)}`);
102
108
  console.log(renderCheckoutView(view));
109
+ if (adapter !== "mobile") renderFeatureFlagLine(staticFeatureFlags, out);
103
110
  if (deviceView) renderMobileDeviceList(deviceView, out);
104
111
  if (!fast && adapter === "mobile" && deviceView !== null && deviceView.allConnectedDevices.length > 0) {
105
112
  const liveView = await mobileDeviceLiveView(target, deviceView);
106
113
  if (liveView.additionalReachableDevices.length > 0) renderAdditionalReachableDevices(liveView.additionalReachableDevices, out);
114
+ renderFeatureFlagLine(featureFlagReport(liveView.featureFlagOverrides), out);
107
115
  renderMobileLiveBlock(devices, liveView.liveMap, out);
108
116
  renderAndroidPortHints(liveView.androidPortHints, out);
109
117
  console.log(`${out("label", "Next:")} ${out("cmd", discoveryNext ?? nextForLive(next, liveView.liveMap))}`);
@@ -112,10 +120,15 @@ async function handleStatus({ options }) {
112
120
  renderRuntimeStatus(runtime, out);
113
121
  console.log(`${out("label", "Next:")} ${out("cmd", nextForRuntime(surface.hints.launch, surface.hints.relaunch, runtime))}`);
114
122
  } else {
123
+ if (adapter === "mobile") renderFeatureFlagLine(featureFlagReport(null), out);
115
124
  console.log(`${out("label", "Next:")} ${out("cmd", discoveryNext ?? next)}`);
116
125
  }
117
126
  return EXIT.ok;
118
127
  }
128
+ function renderFeatureFlagLine(report, out) {
129
+ const style = report.error ? "warn" : (report.overrideCount ?? 0) > 0 ? "warn" : "dim";
130
+ console.log(`${out("label", "feature flags:")} ${out(style, report.summary.replace(/^feature flags: /u, ""))}`);
131
+ }
119
132
  function renderRuntimeStatus(runtime, out) {
120
133
  const decisionStyle = runtime.decision === "ready" ? "ok" : runtime.decision === "blocked" ? "err" : "warn";
121
134
  const deps = runtime.deps ? ` deps=${out(runtime.deps === "current" ? "ok" : "warn", runtime.deps)}` : "";
package/dist/doctor.js CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import path from "node:path";
4
4
  import { color } from "./cli-color.js";
5
+ import { normalizeFeatureFlagRecord } from "./feature-flags.js";
5
6
  import {
6
7
  mobileToolRecovery,
7
8
  resolveMobileToolPath
@@ -85,11 +86,18 @@ function fixtureFileSummary(file, adapter, reportedPath = file) {
85
86
  const data = readJsonObject(file);
86
87
  const hasAccounts = Array.isArray(data.accounts) && data.accounts.length > 0;
87
88
  const hasPassword = typeof data.password === "string" && data.password.length > 0;
89
+ const remoteFeatureFlags = normalizeFeatureFlagRecord(
90
+ data["remoteFeatureFlags"],
91
+ String(reportedPath ?? file)
92
+ );
93
+ const flagKeys = Object.keys(remoteFeatureFlags);
88
94
  return {
89
95
  status: hasAccounts && (adapter === "core" || hasPassword) ? "ready" : "incomplete",
90
96
  path: reportedPath,
91
97
  accountCount: Array.isArray(data.accounts) ? data.accounts.length : 0,
92
- hasPassword
98
+ hasPassword,
99
+ remoteFeatureFlagCount: flagKeys.length,
100
+ ...flagKeys.length > 0 ? { remoteFeatureFlags } : {}
93
101
  };
94
102
  } catch (error) {
95
103
  return { status: "invalid", path: reportedPath, error: error instanceof Error ? error.message : String(error) };
@@ -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
+ };
@@ -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
package/dist/task-view.js CHANGED
@@ -7,8 +7,12 @@ import {
7
7
  deriveChecklistStepDurations,
8
8
  enumerateChecklistCheckboxes,
9
9
  INTERACTIVE_CHECKLIST_MARKDOWN,
10
+ isSettledSubtaskStatus,
10
11
  isTerminalWorkerSignalStatus,
11
12
  READINESS_RECORD,
13
+ SUBTASK_INDEX_FILE,
14
+ SUBTASKS_DIR,
15
+ subtaskPaths,
12
16
  WORKER_SIGNAL_FILE
13
17
  } from "@farmslot/protocol";
14
18
  import { readCommandJournal } from "./command-journal.js";
@@ -28,7 +32,9 @@ function collectTaskView(target, taskDir, now) {
28
32
  ({ name, root }) => ({ ...name ? { name } : {}, path: root })
29
33
  )
30
34
  };
31
- if (!taskDir) return { ...base, checklist: [], activity: { ongoing: false, reason: "no-task" } };
35
+ if (!taskDir) {
36
+ return { ...base, checklist: [], subtasks: [], activity: { ongoing: false, reason: "no-task" } };
37
+ }
32
38
  const handoff = readJsonObject(path.join(taskDir, HANDOFF_INPUT));
33
39
  const fixtures = readJsonObject(path.join(taskDir, "artifacts", "prepare", "fixtures.json"));
34
40
  const sandbox = readJsonObject(path.join(taskDir, "artifacts", READINESS_RECORD)) ?? readJsonObject(path.join(target, recipeRuntimeDir(), READINESS_RECORD));
@@ -81,6 +87,7 @@ function collectTaskView(target, taskDir, now) {
81
87
  }),
82
88
  sandbox: section({ ready: pickBoolean(sandbox, "ready") }),
83
89
  checklist,
90
+ subtasks: readSubtasks(taskDir),
84
91
  signal: section({
85
92
  status: pickString(signal, "status"),
86
93
  step: pickString(signal, "step"),
@@ -167,6 +174,32 @@ function readChecklistRows(file) {
167
174
  checked: item.checked
168
175
  }));
169
176
  }
177
+ function readSubtasks(taskDir) {
178
+ const index = readJsonObject(path.join(taskDir, SUBTASKS_DIR, SUBTASK_INDEX_FILE));
179
+ if (!index) return [];
180
+ const units = index.units;
181
+ if (!Array.isArray(units)) return [];
182
+ const views = [];
183
+ for (const entry of units) {
184
+ const unit = asObject(entry);
185
+ const id = pickString(unit, "id");
186
+ const parentStep = pickNumber(asObject(unit?.parent), "stepNumber");
187
+ if (id === void 0 || parentStep === void 0) continue;
188
+ const paths = subtaskPaths(id);
189
+ const signal = readJsonObject(path.join(taskDir, paths.signal));
190
+ const status = pickString(signal, "status");
191
+ const source = asObject(unit?.source);
192
+ views.push({
193
+ id,
194
+ parentStep,
195
+ ...status !== void 0 ? { status } : {},
196
+ settled: isSettledSubtaskStatus(status),
197
+ rows: readChecklistRows(path.join(taskDir, paths.checklist)),
198
+ ...section({ kind: pickString(source, "kind"), ref: pickString(source, "ref") }) ? { source: section({ kind: pickString(source, "kind"), ref: pickString(source, "ref") }) } : {}
199
+ });
200
+ }
201
+ return views;
202
+ }
170
203
  function trimLabel(text) {
171
204
  const plain = checklistStepName(text).replace(/\*\*/gu, "").replace(/^\d+\.\s*/u, "").replace(/\s+/gu, " ").trim();
172
205
  if (plain.length <= CHECKLIST_LABEL_LIMIT) return plain;
@@ -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
+ }));