@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.
- package/CHANGELOG.md +21 -0
- package/README.md +48 -0
- package/adapters/extension/build-handoff.cjs +4 -0
- package/adapters/extension/inject.mjs +0 -1
- package/adapters/extension/live.sh +26 -25
- package/adapters/extension/pin-remote-flags.cjs +69 -30
- package/adapters/manifest.json +4 -4
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +16 -0
- package/adapters/mobile/bridge-runtime/setup-wallet.sh +69 -2
- package/adapters/shared/resolve-slot-ports-core.mjs +47 -19
- package/dist/adapters/extension/feature-flags.js +41 -0
- package/dist/adapters/extension/runtime-decision.js +2 -2
- package/dist/adapters/extension/runtime.js +4 -3
- package/dist/adapters/extension/verify.js +608 -0
- package/dist/adapters/mobile/feature-flags.js +127 -0
- package/dist/adapters/resolve-slot-ports.js +2 -0
- package/dist/adapters.js +6 -1
- package/dist/cli.js +1 -1
- package/dist/command-contract.js +6 -3
- package/dist/commands/doctor.js +9 -1
- package/dist/commands/fixtures.js +45 -3
- package/dist/commands/launch/extension.js +28 -6
- package/dist/commands/launch/index.js +128 -17
- package/dist/commands/launch/mobile.js +26 -2
- package/dist/commands/mobile-device-view.js +8 -1
- package/dist/commands/prepare.js +20 -3
- package/dist/commands/run-engine.js +10 -1
- package/dist/commands/runtime-launch.js +10 -1
- package/dist/commands/status-probe.js +14 -1
- package/dist/commands/status.js +13 -0
- package/dist/doctor.js +9 -1
- package/dist/feature-flags.js +193 -0
- package/dist/harness.js +3 -15
- package/dist/mm-harness-cli.js +15 -2
- package/library/actions/core/feature_flags/_unsupported.mjs +29 -0
- package/library/actions/core/feature_flags/clear.mjs +8 -0
- package/library/actions/core/feature_flags/read.mjs +8 -0
- package/library/actions/core/feature_flags/set.mjs +8 -0
- package/library/actions/extension/feature_flags/clear.mjs +48 -0
- package/library/actions/extension/feature_flags/read.mjs +27 -0
- package/library/actions/extension/feature_flags/set.mjs +36 -0
- package/library/actions/extension/feature_flags/state.mjs +129 -0
- package/library/actions/harness-exports.mjs +14 -0
- package/library/actions/mobile/feature_flags/clear.mjs +20 -0
- package/library/actions/mobile/feature_flags/controller.mjs +120 -0
- package/library/actions/mobile/feature_flags/read.mjs +20 -0
- package/library/actions/mobile/feature_flags/set.mjs +25 -0
- package/library/actions/mobile/wallet/reset.mjs +22 -1
- package/library/manifests/core.action-manifest.json +90 -0
- package/library/manifests/extension.action-manifest.json +70 -0
- package/library/manifests/mobile.action-manifest.json +90 -0
- package/package.json +1 -1
- 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
|
-
|
|
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
|
|
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
|
|
334
|
+
exitCode = await runVerify(forwardArgs, {
|
|
347
335
|
out: quiet ? () => {
|
|
348
336
|
} : (line) => {
|
|
349
337
|
(json ? process.stderr : process.stdout).write(`${line}
|
package/dist/mm-harness-cli.js
CHANGED
|
@@ -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
|
+
});
|