@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,120 @@
1
+ // Mobile remote feature-flag transport: RemoteFeatureFlagController over the CDP bridge.
2
+ //
3
+ // `setFlagOverride` writes `state.localOverrides` and recomputes
4
+ // `state.remoteFeatureFlags` as merge(processed remote flags, localOverrides) —
5
+ // overrides persist and win over every later refetch, so a pinned variant
6
+ // survives a ClientConfigApi response. Verified against
7
+ // @metamask/remote-feature-flag-controller
8
+ // dist/remote-feature-flag-controller.cjs (setFlagOverride / removeFlagOverride /
9
+ // clearAllFlagOverrides) and the global Engine in app/core/Engine/Engine.ts.
10
+ import { evalAsync } from '../platform/bridge.mjs';
11
+
12
+ const CONTROLLER_PREAMBLE = `
13
+ var engine = globalThis.Engine;
14
+ var context = engine && engine.context;
15
+ var controller = context && context.RemoteFeatureFlagController;
16
+ if (!controller) {
17
+ return JSON.stringify({
18
+ ok: false,
19
+ error: 'Engine.context.RemoteFeatureFlagController is not available on the running app.',
20
+ });
21
+ }
22
+ var snapshot = function () {
23
+ var state = controller.state || {};
24
+ return {
25
+ effective: state.remoteFeatureFlags || {},
26
+ overrides: state.localOverrides || {},
27
+ };
28
+ };
29
+ `;
30
+
31
+ function evaluate(input, body) {
32
+ return evalAsync(input, `Promise.resolve().then(function () {${CONTROLLER_PREAMBLE}${body}})`);
33
+ }
34
+
35
+ function unwrap(result, action) {
36
+ if (!result || typeof result !== 'object') {
37
+ throw new Error(`${action} did not return a controller result from the mobile bridge.`);
38
+ }
39
+ if (result.ok !== true) {
40
+ throw new Error(String(result.error || `${action} failed on the mobile runtime.`));
41
+ }
42
+ return result;
43
+ }
44
+
45
+ /** Read `{ effective, overrides }` straight from controller state. */
46
+ export async function readMobileFlags(input) {
47
+ const result = await evaluate(input, `
48
+ var current = snapshot();
49
+ return JSON.stringify({ ok: true, effective: current.effective, overrides: current.overrides });
50
+ `);
51
+ return unwrap(result, 'metamask.feature_flags.read');
52
+ }
53
+
54
+ /** Apply every normalised flag, then read the controller back in the same eval. */
55
+ export async function setMobileFlags(input, flags) {
56
+ const payload = JSON.stringify(JSON.stringify(flags));
57
+ const result = await evaluate(input, `
58
+ if (typeof controller.setFlagOverride !== 'function') {
59
+ return JSON.stringify({
60
+ ok: false,
61
+ error: 'RemoteFeatureFlagController.setFlagOverride is unavailable in this build; update @metamask/remote-feature-flag-controller in the checkout.',
62
+ });
63
+ }
64
+ var requested = JSON.parse(${payload});
65
+ var applied = [];
66
+ for (var key in requested) {
67
+ if (Object.prototype.hasOwnProperty.call(requested, key)) {
68
+ controller.setFlagOverride(key, requested[key]);
69
+ applied.push(key);
70
+ }
71
+ }
72
+ var current = snapshot();
73
+ return JSON.stringify({ ok: true, applied: applied, effective: current.effective, overrides: current.overrides });
74
+ `);
75
+ return unwrap(result, 'metamask.feature_flags.set');
76
+ }
77
+
78
+ /** Drop every override so the slot never leaks a variant into the next run. */
79
+ export async function clearMobileFlags(input) {
80
+ const result = await evaluate(input, `
81
+ if (typeof controller.clearAllFlagOverrides !== 'function') {
82
+ return JSON.stringify({
83
+ ok: false,
84
+ error: 'RemoteFeatureFlagController.clearAllFlagOverrides is unavailable in this build; update @metamask/remote-feature-flag-controller in the checkout.',
85
+ });
86
+ }
87
+ var before = Object.keys((controller.state || {}).localOverrides || {});
88
+ controller.clearAllFlagOverrides();
89
+ var current = snapshot();
90
+ return JSON.stringify({ ok: true, cleared: before, effective: current.effective, overrides: current.overrides });
91
+ `);
92
+ return unwrap(result, 'metamask.feature_flags.clear');
93
+ }
94
+
95
+ /** Restrict an effective/override map to the requested keys, when `keys` is given. */
96
+ export function selectKeys(map, keys) {
97
+ if (!Array.isArray(keys) || keys.length === 0) return map;
98
+ const selected = {};
99
+ // An absent key reads as null rather than disappearing: a caller that asked
100
+ // about a key deserves an answer about it, and JSON drops undefined.
101
+ for (const key of keys) selected[key] = map[key] === undefined ? null : map[key];
102
+ return selected;
103
+ }
104
+
105
+ /**
106
+ * Read the requested keys back and fail loudly when the runtime disagrees with
107
+ * what was asked for. Applying a flag nobody can observe is not proof.
108
+ */
109
+ export function assertFlagsReadBack(requested, effective, action) {
110
+ const mismatched = Object.keys(requested).filter(
111
+ (key) => JSON.stringify(effective[key]) !== JSON.stringify(requested[key]),
112
+ );
113
+ if (mismatched.length > 0) {
114
+ throw new Error(
115
+ `${action} did not read back: ${mismatched
116
+ .map((key) => `${key} expected ${JSON.stringify(requested[key])}, got ${JSON.stringify(effective[key])}`)
117
+ .join('; ')}.`,
118
+ );
119
+ }
120
+ }
@@ -0,0 +1,20 @@
1
+ // Read the effective remote feature flags and the local overrides from the running app.
2
+ import { runAdapter } from '../platform/bridge.mjs';
3
+ import { readMobileFlags, selectKeys } from './controller.mjs';
4
+
5
+ runAdapter(async (input) => {
6
+ const keys = Array.isArray(input.node?.keys) ? input.node.keys.map(String) : [];
7
+ // Read only. The keys here are the app's, not the harness's, so they are
8
+ // reported as the controller holds them: validating app-owned key names on a
9
+ // read would fail the read over a flag the harness never authored.
10
+ const { effective, overrides } = await readMobileFlags(input);
11
+ return {
12
+ action: input.action,
13
+ source: 'mobile-remote-feature-flag-controller',
14
+ effective: selectKeys(effective, keys),
15
+ overrides: selectKeys(overrides, keys),
16
+ overrideCount: Object.keys(overrides).length,
17
+ supported: true,
18
+ proofPath: 'mobile-remote-feature-flags',
19
+ };
20
+ });
@@ -0,0 +1,25 @@
1
+ // Pin remote feature-flag overrides on the running app, then read them back.
2
+ import { normalizeFeatureFlagRecord } from '../../harness-exports.mjs';
3
+ import { runAdapter } from '../platform/bridge.mjs';
4
+ import { assertFlagsReadBack, setMobileFlags } from './controller.mjs';
5
+
6
+ runAdapter(async (input) => {
7
+ const requested = normalizeFeatureFlagRecord(
8
+ input.node?.flags,
9
+ 'metamask.feature_flags.set',
10
+ );
11
+ if (Object.keys(requested).length === 0) {
12
+ throw new Error('metamask.feature_flags.set requires a non-empty flags object, e.g. flags={"perpsTAT3938AbtestScreenVsBottomSheet":"treatment"}.');
13
+ }
14
+ const { applied, effective, overrides } = await setMobileFlags(input, requested);
15
+ assertFlagsReadBack(requested, effective, 'metamask.feature_flags.set');
16
+ return {
17
+ action: input.action,
18
+ source: 'mobile-remote-feature-flag-controller',
19
+ applied,
20
+ overrides,
21
+ effective,
22
+ supported: true,
23
+ proofPath: 'mobile-remote-feature-flags',
24
+ };
25
+ });
@@ -1,21 +1,42 @@
1
1
  // Reset the wallet through visible UI and verify fresh onboarding using the bridge or native accessibility.
2
2
  import { runAdapter } from '../platform/bridge.mjs';
3
+ import { clearMobileFlags } from '../feature_flags/controller.mjs';
3
4
  import { resetMobileWalletThroughUi } from './reset-helper.mjs';
4
5
  import { isOpaqueMobileRuntime, resetWalletThroughNativeUi } from './native-ui.mjs';
5
6
 
7
+ // A reset that leaves a pinned A/B variant behind would silently carry one run's
8
+ // variant into the next. Overrides live in the controller, not the vault, so the
9
+ // visible reset flow does not drop them on its own.
10
+ //
11
+ // The opaque-runtime path drives native accessibility precisely because the JS
12
+ // bridge is unavailable there, so the controller cannot be reached and the
13
+ // override state is reported as unattempted rather than failing the reset or
14
+ // claiming a clear that never happened.
15
+ async function clearFeatureFlagOverrides(input) {
16
+ const { cleared } = await clearMobileFlags(input);
17
+ return { attempted: true, cleared };
18
+ }
19
+
6
20
  runAdapter(async (input) => {
7
21
  if (isOpaqueMobileRuntime(input)) {
8
22
  const result = await resetWalletThroughNativeUi(input);
9
23
  return {
10
24
  action: input.action,
11
25
  ...result,
26
+ featureFlagOverrides: {
27
+ attempted: false,
28
+ reason: 'The opaque mobile runtime has no JS bridge, so RemoteFeatureFlagController is unreachable from this reset path.',
29
+ cleared: [],
30
+ },
12
31
  route: 'Onboarding',
13
32
  redacted: true,
14
33
  proofPath: 'mobile-visible-wallet-reset-native',
15
34
  };
16
35
  }
36
+ const reset = await resetMobileWalletThroughUi(input);
17
37
  return {
18
38
  action: input.action,
19
- ...(await resetMobileWalletThroughUi(input)),
39
+ ...reset,
40
+ featureFlagOverrides: await clearFeatureFlagOverrides(input),
20
41
  };
21
42
  });
@@ -1,6 +1,96 @@
1
1
  {
2
2
  "$schema": "https://farmslot.io/schemas/action-manifest-v1.schema.json",
3
3
  "actions": {
4
+ "metamask.feature_flags.set": {
5
+ "description": "Report that core, the headless adapter, holds no remote feature flags to override.",
6
+ "schema": {
7
+ "type": "object",
8
+ "properties": {
9
+ "flags": {
10
+ "type": "object",
11
+ "description": "Flag key to value. A bare word is a named A/B variant (\"treatment\" becomes { \"name\": \"treatment\" }); true/false is a boolean; an object or array is used as authored."
12
+ },
13
+ "controller_ready_timeout_ms": {
14
+ "type": "number",
15
+ "minimum": 1,
16
+ "description": "Maximum time in milliseconds to wait for the app runtime to accept the override."
17
+ }
18
+ },
19
+ "required": [
20
+ "flags"
21
+ ],
22
+ "additionalProperties": false
23
+ },
24
+ "examples": [
25
+ {
26
+ "action": "metamask.feature_flags.set",
27
+ "flags": {
28
+ "perpsTAT3938AbtestScreenVsBottomSheet": "treatment"
29
+ },
30
+ "intent": "Pin the A/B variant the proof runs under",
31
+ "next": "done"
32
+ }
33
+ ],
34
+ "execution_capabilities": [
35
+ "app-mutation"
36
+ ]
37
+ },
38
+ "metamask.feature_flags.read": {
39
+ "description": "Report that core, the headless adapter, holds no remote feature flags to read.",
40
+ "schema": {
41
+ "type": "object",
42
+ "properties": {
43
+ "keys": {
44
+ "type": "array",
45
+ "items": {
46
+ "type": "string"
47
+ },
48
+ "description": "Restrict the reported flags to these keys. Omit to report every flag."
49
+ },
50
+ "controller_ready_timeout_ms": {
51
+ "type": "number",
52
+ "minimum": 1,
53
+ "description": "Maximum time in milliseconds to wait for the app runtime to answer."
54
+ }
55
+ },
56
+ "additionalProperties": false
57
+ },
58
+ "examples": [
59
+ {
60
+ "action": "metamask.feature_flags.read",
61
+ "keys": [
62
+ "perpsTAT3938AbtestScreenVsBottomSheet"
63
+ ],
64
+ "intent": "Record the A/B variant the proof ran under",
65
+ "next": "done"
66
+ }
67
+ ],
68
+ "execution_capabilities": []
69
+ },
70
+ "metamask.feature_flags.clear": {
71
+ "description": "Report that core, the headless adapter, holds no remote feature-flag overrides to clear.",
72
+ "schema": {
73
+ "type": "object",
74
+ "properties": {
75
+ "controller_ready_timeout_ms": {
76
+ "type": "number",
77
+ "minimum": 1,
78
+ "description": "Maximum time in milliseconds to wait for the app runtime to answer."
79
+ }
80
+ },
81
+ "additionalProperties": false
82
+ },
83
+ "examples": [
84
+ {
85
+ "action": "metamask.feature_flags.clear",
86
+ "intent": "Drop the pinned variant before the next run",
87
+ "next": "done"
88
+ }
89
+ ],
90
+ "execution_capabilities": [
91
+ "app-mutation"
92
+ ]
93
+ },
4
94
  "command": {
5
95
  "description": "Run a shell command from the project root.",
6
96
  "examples": [
@@ -1,6 +1,76 @@
1
1
  {
2
2
  "$schema": "https://farmslot.io/schemas/action-manifest-v1.schema.json",
3
3
  "actions": {
4
+ "metamask.feature_flags.set": {
5
+ "description": "Report that the extension cannot pin remote feature flags at runtime and name the relaunch that can. The extension pins flags into the per-run dist snapshot manifest at launch.",
6
+ "schema": {
7
+ "type": "object",
8
+ "properties": {
9
+ "flags": {
10
+ "type": "object",
11
+ "description": "Flag key to value. A bare word is a named A/B variant (\"treatment\" becomes { \"name\": \"treatment\" }); true/false is a boolean; an object or array is used as authored."
12
+ }
13
+ },
14
+ "required": [
15
+ "flags"
16
+ ],
17
+ "additionalProperties": false
18
+ },
19
+ "examples": [
20
+ {
21
+ "action": "metamask.feature_flags.set",
22
+ "flags": {
23
+ "perpsTAT3938AbtestScreenVsBottomSheet": "treatment"
24
+ },
25
+ "intent": "Pin the A/B variant the proof runs under",
26
+ "next": "done"
27
+ }
28
+ ],
29
+ "execution_capabilities": []
30
+ },
31
+ "metamask.feature_flags.read": {
32
+ "description": "Read the effective remote feature flags, the manifest pin, and the fetched flags from the running extension.",
33
+ "schema": {
34
+ "type": "object",
35
+ "properties": {
36
+ "keys": {
37
+ "type": "array",
38
+ "items": {
39
+ "type": "string"
40
+ },
41
+ "description": "Restrict the reported flags to these keys. Omit to report every flag."
42
+ }
43
+ },
44
+ "additionalProperties": false
45
+ },
46
+ "examples": [
47
+ {
48
+ "action": "metamask.feature_flags.read",
49
+ "keys": [
50
+ "perpsTAT3938AbtestScreenVsBottomSheet"
51
+ ],
52
+ "intent": "Record the A/B variant the proof ran under",
53
+ "next": "done"
54
+ }
55
+ ],
56
+ "execution_capabilities": []
57
+ },
58
+ "metamask.feature_flags.clear": {
59
+ "description": "Confirm no manifest flag pin is active, or name the relaunch that drops it.",
60
+ "schema": {
61
+ "type": "object",
62
+ "properties": {},
63
+ "additionalProperties": false
64
+ },
65
+ "examples": [
66
+ {
67
+ "action": "metamask.feature_flags.clear",
68
+ "intent": "Drop the pinned variant before the next run",
69
+ "next": "done"
70
+ }
71
+ ],
72
+ "execution_capabilities": []
73
+ },
4
74
  "command": {
5
75
  "description": "Run a shell command from the project root.",
6
76
  "examples": [
@@ -1,6 +1,96 @@
1
1
  {
2
2
  "$schema": "https://farmslot.io/schemas/action-manifest-v1.schema.json",
3
3
  "actions": {
4
+ "metamask.feature_flags.set": {
5
+ "description": "Pin remote feature-flag overrides on the running app and read them back. Overrides persist and win over every later remote refetch.",
6
+ "schema": {
7
+ "type": "object",
8
+ "properties": {
9
+ "flags": {
10
+ "type": "object",
11
+ "description": "Flag key to value. A bare word is a named A/B variant (\"treatment\" becomes { \"name\": \"treatment\" }); true/false is a boolean; an object or array is used as authored."
12
+ },
13
+ "controller_ready_timeout_ms": {
14
+ "type": "number",
15
+ "minimum": 1,
16
+ "description": "Maximum time in milliseconds to wait for the app runtime to accept the override."
17
+ }
18
+ },
19
+ "required": [
20
+ "flags"
21
+ ],
22
+ "additionalProperties": false
23
+ },
24
+ "examples": [
25
+ {
26
+ "action": "metamask.feature_flags.set",
27
+ "flags": {
28
+ "perpsTAT3938AbtestScreenVsBottomSheet": "treatment"
29
+ },
30
+ "intent": "Pin the A/B variant the proof runs under",
31
+ "next": "done"
32
+ }
33
+ ],
34
+ "execution_capabilities": [
35
+ "app-mutation"
36
+ ]
37
+ },
38
+ "metamask.feature_flags.read": {
39
+ "description": "Read the effective remote feature flags and the local overrides from the running app.",
40
+ "schema": {
41
+ "type": "object",
42
+ "properties": {
43
+ "keys": {
44
+ "type": "array",
45
+ "items": {
46
+ "type": "string"
47
+ },
48
+ "description": "Restrict the reported flags to these keys. Omit to report every flag."
49
+ },
50
+ "controller_ready_timeout_ms": {
51
+ "type": "number",
52
+ "minimum": 1,
53
+ "description": "Maximum time in milliseconds to wait for the app runtime to answer."
54
+ }
55
+ },
56
+ "additionalProperties": false
57
+ },
58
+ "examples": [
59
+ {
60
+ "action": "metamask.feature_flags.read",
61
+ "keys": [
62
+ "perpsTAT3938AbtestScreenVsBottomSheet"
63
+ ],
64
+ "intent": "Record the A/B variant the proof ran under",
65
+ "next": "done"
66
+ }
67
+ ],
68
+ "execution_capabilities": []
69
+ },
70
+ "metamask.feature_flags.clear": {
71
+ "description": "Drop every local remote feature-flag override so the slot leaks no variant into the next run.",
72
+ "schema": {
73
+ "type": "object",
74
+ "properties": {
75
+ "controller_ready_timeout_ms": {
76
+ "type": "number",
77
+ "minimum": 1,
78
+ "description": "Maximum time in milliseconds to wait for the app runtime to answer."
79
+ }
80
+ },
81
+ "additionalProperties": false
82
+ },
83
+ "examples": [
84
+ {
85
+ "action": "metamask.feature_flags.clear",
86
+ "intent": "Drop the pinned variant before the next run",
87
+ "next": "done"
88
+ }
89
+ ],
90
+ "execution_capabilities": [
91
+ "app-mutation"
92
+ ]
93
+ },
4
94
  "command": {
5
95
  "description": "Run a shell command from the project root.",
6
96
  "examples": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.58.0",
3
+ "version": "0.60.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"