@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.
- package/CHANGELOG.md +31 -0
- package/README.md +48 -0
- package/adapters/extension/build-handoff.cjs +4 -0
- package/adapters/extension/live.sh +15 -11
- package/adapters/extension/pin-remote-flags.cjs +69 -30
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +16 -0
- package/adapters/mobile/bridge-runtime/setup-wallet.sh +69 -2
- package/dist/adapters/extension/feature-flags.js +41 -0
- package/dist/adapters/extension/runtime-decision.js +1 -0
- package/dist/adapters/mobile/feature-flags.js +127 -0
- package/dist/adapters.js +6 -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-watch.js +32 -0
- package/dist/commands/status.js +13 -0
- package/dist/doctor.js +9 -1
- package/dist/feature-flags.js +193 -0
- package/dist/mm-harness-cli.js +15 -2
- package/dist/task-view.js +34 -1
- 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/platform/bridge.mjs +2 -2
- 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 +5 -5
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -927,7 +927,7 @@ export async function simulatorScreenshot(input, relPath) {
|
|
|
927
927
|
path: relative,
|
|
928
928
|
type: 'screenshot',
|
|
929
929
|
nodeId: input.context.nodeId,
|
|
930
|
-
label: input.node?.description || `${input.action} screenshot`,
|
|
930
|
+
label: input.node?.label || input.node?.description || `${input.action} screenshot`,
|
|
931
931
|
category: 'evidence',
|
|
932
932
|
mimeType: 'image/png',
|
|
933
933
|
metadata: {
|
|
@@ -976,7 +976,7 @@ async function androidScreenshot(input, relPath) {
|
|
|
976
976
|
path: relative,
|
|
977
977
|
type: 'screenshot',
|
|
978
978
|
nodeId: input.context.nodeId,
|
|
979
|
-
label: input.node?.description || `${input.action} screenshot`,
|
|
979
|
+
label: input.node?.label || input.node?.description || `${input.action} screenshot`,
|
|
980
980
|
category: 'evidence',
|
|
981
981
|
mimeType: 'image/png',
|
|
982
982
|
metadata: {
|
|
@@ -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
|
-
...
|
|
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": [
|