@deeeed/metamask-harness 0.35.0 → 0.37.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 +39 -0
- package/README.md +9 -1
- package/adapters/extension/artifact-runtime-state.cjs +128 -0
- package/adapters/extension/check-infura-readiness.cjs +102 -0
- package/adapters/extension/inject.mjs +2 -0
- package/adapters/extension/launch-browser.cjs +22 -0
- package/adapters/extension/live.sh +103 -18
- package/adapters/extension/readiness.mjs +77 -36
- package/adapters/extension/snapshot-dist.sh +88 -3
- package/adapters/extension/start-watch.sh +15 -0
- package/adapters/extension/verify.sh +6 -2
- package/adapters/extension/wallet-fixture-state.cjs +3 -1
- package/adapters/manifest.json +25 -1
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +73 -42
- package/adapters/mobile/reset-app-data.sh +154 -0
- package/adapters/shared/log-tui.mjs +1 -1
- package/dist/adapters/extension/artifact-integrity.js +38 -0
- package/dist/adapters/extension/extension-id.js +23 -4
- package/dist/adapters/extension/product-config.js +29 -1
- package/dist/adapters/extension/release-artifact.js +386 -0
- package/dist/adapters/extension/runtime-decision.js +161 -20
- package/dist/adapters/extension/runtime.js +127 -0
- package/dist/adapters/mobile/release-artifact-state.js +124 -0
- package/dist/adapters/mobile/release-artifact.js +295 -0
- package/dist/adapters.js +29 -4
- package/dist/cli-commands.js +1 -1
- package/dist/cli.js +2 -2
- package/dist/command-contract.js +17 -1
- package/dist/commands/call.js +2 -1
- package/dist/commands/device-target.js +5 -0
- package/dist/commands/fixtures.js +106 -31
- package/dist/commands/launch/extension.js +92 -7
- package/dist/commands/launch/index.js +13 -0
- package/dist/commands/launch/mobile.js +2 -0
- package/dist/commands/provision.js +2 -0
- package/dist/commands/run-engine.js +89 -5
- package/dist/commands/run.js +3 -1
- package/dist/commands/runtime-launch.js +178 -10
- package/dist/heal-bounds.js +1 -1
- package/dist/live-adapter-contract.js +3 -1
- package/dist/metamask-action-validation.js +47 -1
- package/dist/mm-harness-cli.js +37 -4
- package/dist/recipe-security.js +3 -0
- package/dist/run-diagnostics.js +1 -1
- package/docs/RECIPES.md +29 -0
- package/docs/RELEASE-QA-CAPABILITY-MAP.md +150 -0
- package/library/actions/extension/perps/perps.mjs +2 -0
- package/library/actions/extension/perps/read_snapshot.mjs +470 -0
- package/library/actions/extension/platform/cdp.mjs +6 -3
- package/library/actions/extension/wallet/import.mjs +201 -0
- package/library/actions/extension/wallet/reset.mjs +98 -0
- package/library/actions/extension/wallet/secret-input.mjs +98 -0
- package/library/actions/extension/wallet/state.mjs +1 -0
- package/library/actions/mobile/analytics/consent-settings.mjs +112 -0
- package/library/actions/mobile/analytics/set_consent.mjs +4 -112
- package/library/actions/mobile/platform/bridge.mjs +8 -0
- package/library/actions/mobile/platform/observe-ui.mjs +84 -2
- package/library/actions/mobile/ui/native-navigation.mjs +225 -0
- package/library/actions/mobile/ui/navigate.mjs +7 -0
- package/library/actions/mobile/wallet/import.mjs +328 -0
- package/library/actions/mobile/wallet/native-ui.mjs +493 -0
- package/library/actions/mobile/wallet/read_state.mjs +16 -0
- package/library/actions/mobile/wallet/reset-helper.mjs +99 -0
- package/library/actions/mobile/wallet/reset.mjs +20 -0
- package/library/actions/shared/ui/locators.mjs +7 -0
- package/library/actions/shared/wallet/import-source.mjs +101 -0
- package/library/manifests/extension.action-manifest.json +228 -0
- package/library/manifests/mobile.action-manifest.json +124 -0
- package/library/recipes/extension/runner/action-validation.recipe.json +12 -1
- package/library/recipes/wallet/import.recipe.json +102 -0
- package/library/recipes/wallet/reset-import.recipe.json +107 -0
- package/package.json +1 -1
- package/scripts/completions.sh +2 -2
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
2
|
+
import { readWalletStateExpression } from './state.mjs';
|
|
3
|
+
|
|
4
|
+
const SELECTORS = {
|
|
5
|
+
accountMenu: '[data-testid="account-options-menu-button"]',
|
|
6
|
+
lock: '[data-testid="global-menu-lock"]',
|
|
7
|
+
forgotPassword: '[data-testid="unlock-forgot-password-button"]',
|
|
8
|
+
resetChoice: '[data-testid="reset-password-modal-button-link"]',
|
|
9
|
+
confirmReset: '[data-testid="reset-password-modal-button"]',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
async function hasSelector(page, selector) {
|
|
13
|
+
return page.evaluate(`Boolean(document.querySelector(${JSON.stringify(selector)}))`).catch(() => false);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function waitForSelector(page, selector, timeoutMs) {
|
|
17
|
+
const deadline = Date.now() + timeoutMs;
|
|
18
|
+
while (Date.now() <= deadline) {
|
|
19
|
+
if (await hasSelector(page, selector)) return;
|
|
20
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
21
|
+
}
|
|
22
|
+
throw new Error(`Wallet reset timed out waiting for ${selector}.`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function clickWhenReady(page, selector, timeoutMs) {
|
|
26
|
+
const deadline = Date.now() + timeoutMs;
|
|
27
|
+
let lastError;
|
|
28
|
+
while (Date.now() <= deadline) {
|
|
29
|
+
if (await hasSelector(page, selector)) {
|
|
30
|
+
try {
|
|
31
|
+
await page.click(selector);
|
|
32
|
+
return;
|
|
33
|
+
} catch (error) {
|
|
34
|
+
const message = String(error?.message ?? error);
|
|
35
|
+
if (!message.includes('Target is obscured') && !message.includes('Target is not visible')) {
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
lastError = error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
42
|
+
}
|
|
43
|
+
throw lastError ?? new Error(`Wallet reset timed out waiting for ${selector}.`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function readState(page) {
|
|
47
|
+
return page.evaluate(readWalletStateExpression(), { awaitPromise: true });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isFreshWallet(state) {
|
|
51
|
+
return !state.completedOnboarding &&
|
|
52
|
+
String(state.href ?? '').includes('/onboarding') &&
|
|
53
|
+
!state.selectedAccount;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function waitForFreshWallet(page, timeoutMs) {
|
|
57
|
+
const deadline = Date.now() + timeoutMs;
|
|
58
|
+
let observed;
|
|
59
|
+
while (Date.now() <= deadline) {
|
|
60
|
+
observed = await readState(page);
|
|
61
|
+
if (isFreshWallet(observed)) return observed;
|
|
62
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
63
|
+
}
|
|
64
|
+
throw new Error('Extension wallet reset did not clear onboarding and the selected account.');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
runAdapter((input) => withExtensionPage(input, async (page) => {
|
|
68
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
|
|
69
|
+
const before = await readState(page);
|
|
70
|
+
if (isFreshWallet(before)) {
|
|
71
|
+
return {
|
|
72
|
+
action: input.action,
|
|
73
|
+
alreadyFresh: true,
|
|
74
|
+
completedOnboarding: false,
|
|
75
|
+
redacted: true,
|
|
76
|
+
proofPath: 'extension-visible-wallet-reset',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!before.passwordInput) {
|
|
81
|
+
await page.navigateHash('#/');
|
|
82
|
+
await waitForSelector(page, SELECTORS.accountMenu, timeoutMs);
|
|
83
|
+
await clickWhenReady(page, SELECTORS.accountMenu, timeoutMs);
|
|
84
|
+
await clickWhenReady(page, SELECTORS.lock, timeoutMs);
|
|
85
|
+
await waitForSelector(page, SELECTORS.forgotPassword, timeoutMs);
|
|
86
|
+
}
|
|
87
|
+
await clickWhenReady(page, SELECTORS.forgotPassword, timeoutMs);
|
|
88
|
+
await clickWhenReady(page, SELECTORS.resetChoice, timeoutMs);
|
|
89
|
+
await clickWhenReady(page, SELECTORS.confirmReset, timeoutMs);
|
|
90
|
+
const after = await waitForFreshWallet(page, timeoutMs);
|
|
91
|
+
return {
|
|
92
|
+
action: input.action,
|
|
93
|
+
alreadyFresh: false,
|
|
94
|
+
completedOnboarding: after.completedOnboarding,
|
|
95
|
+
redacted: true,
|
|
96
|
+
proofPath: 'extension-visible-wallet-reset',
|
|
97
|
+
};
|
|
98
|
+
}));
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export async function setSecretInput(page, selector, value) {
|
|
2
|
+
const found = await page.evaluate(`(() => {
|
|
3
|
+
const element = document.querySelector(${JSON.stringify(selector)});
|
|
4
|
+
if (!element) return false;
|
|
5
|
+
element.focus();
|
|
6
|
+
if (typeof element.select === 'function') element.select();
|
|
7
|
+
return true;
|
|
8
|
+
})()`);
|
|
9
|
+
if (!found) throw new Error('Wallet import secret input was not found.');
|
|
10
|
+
|
|
11
|
+
await page.session.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Backspace' });
|
|
12
|
+
await page.session.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace' });
|
|
13
|
+
await page.session.call('Input.insertText', { text: value });
|
|
14
|
+
|
|
15
|
+
const retainedLength = await page.evaluate(`(() => {
|
|
16
|
+
const element = document.querySelector(${JSON.stringify(selector)});
|
|
17
|
+
if (!element) return -1;
|
|
18
|
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
19
|
+
return typeof element.value === 'string' ? element.value.length : -1;
|
|
20
|
+
})()`);
|
|
21
|
+
if (retainedLength !== value.length) {
|
|
22
|
+
throw new Error('Wallet import secret input did not retain the supplied value.');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function pasteSecretRecoveryPhrase(page, selector, value, timeoutMs) {
|
|
27
|
+
const bridgeKey = 'mm-harness.wallet.recovery-phrase-paste';
|
|
28
|
+
const preload = await page.session.call('Page.addScriptToEvaluateOnNewDocument', {
|
|
29
|
+
source: `(() => {
|
|
30
|
+
const bridgeKey = Symbol.for(${JSON.stringify(bridgeKey)});
|
|
31
|
+
const NativeDataTransfer = globalThis.DataTransfer;
|
|
32
|
+
const NativeClipboardEvent = globalThis.ClipboardEvent;
|
|
33
|
+
Object.defineProperty(globalThis, bridgeKey, {
|
|
34
|
+
configurable: true,
|
|
35
|
+
value(selector) {
|
|
36
|
+
const element = document.querySelector(selector);
|
|
37
|
+
if (!element || typeof element.value !== 'string') return false;
|
|
38
|
+
const clipboardData = new NativeDataTransfer();
|
|
39
|
+
clipboardData.setData('text', element.value);
|
|
40
|
+
element.focus();
|
|
41
|
+
element.dispatchEvent(new NativeClipboardEvent('paste', {
|
|
42
|
+
bubbles: true,
|
|
43
|
+
cancelable: true,
|
|
44
|
+
clipboardData,
|
|
45
|
+
}));
|
|
46
|
+
return true;
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
})();`,
|
|
50
|
+
});
|
|
51
|
+
if (typeof preload?.identifier !== 'string' || preload.identifier.length === 0) {
|
|
52
|
+
throw new Error('Wallet import recovery phrase preload was not installed.');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let actionError;
|
|
56
|
+
try {
|
|
57
|
+
await page.session.call('Page.reload', { ignoreCache: false });
|
|
58
|
+
const deadline = Date.now() + timeoutMs;
|
|
59
|
+
while (Date.now() <= deadline) {
|
|
60
|
+
const found = await page.evaluate(
|
|
61
|
+
`Boolean(document.querySelector(${JSON.stringify(selector)}))`,
|
|
62
|
+
).catch(() => false);
|
|
63
|
+
if (found) break;
|
|
64
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
65
|
+
}
|
|
66
|
+
await setSecretInput(page, selector, value);
|
|
67
|
+
const pasted = await page.evaluate(`(() => {
|
|
68
|
+
const bridge = globalThis[Symbol.for(${JSON.stringify(bridgeKey)})];
|
|
69
|
+
return typeof bridge === 'function' && bridge(${JSON.stringify(selector)});
|
|
70
|
+
})()`);
|
|
71
|
+
if (!pasted) throw new Error('Wallet import Secret Recovery Phrase input was not found.');
|
|
72
|
+
} catch (error) {
|
|
73
|
+
actionError = error;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const cleanupErrors = [];
|
|
77
|
+
try {
|
|
78
|
+
const removed = await page.evaluate(`(() => {
|
|
79
|
+
const key = Symbol.for(${JSON.stringify(bridgeKey)});
|
|
80
|
+
delete globalThis[key];
|
|
81
|
+
return !(key in globalThis);
|
|
82
|
+
})()`);
|
|
83
|
+
if (removed !== true) cleanupErrors.push('page bridge removal was not confirmed');
|
|
84
|
+
} catch (error) {
|
|
85
|
+
cleanupErrors.push(error instanceof Error ? error.message : String(error));
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
await page.session.call('Page.removeScriptToEvaluateOnNewDocument', {
|
|
89
|
+
identifier: preload.identifier,
|
|
90
|
+
});
|
|
91
|
+
} catch (error) {
|
|
92
|
+
cleanupErrors.push(error instanceof Error ? error.message : String(error));
|
|
93
|
+
}
|
|
94
|
+
if (cleanupErrors.length > 0) {
|
|
95
|
+
throw new Error(`Wallet import recovery phrase bridge cleanup failed: ${cleanupErrors.join('; ')}.`);
|
|
96
|
+
}
|
|
97
|
+
if (actionError) throw actionError;
|
|
98
|
+
}
|
|
@@ -38,6 +38,7 @@ export function readWalletStateExpression() {
|
|
|
38
38
|
type: selected.type || null,
|
|
39
39
|
} : null,
|
|
40
40
|
completedOnboarding: Boolean(metamask.completedOnboarding),
|
|
41
|
+
metametricsOptedIn: typeof metamask.optedIn === 'boolean' ? metamask.optedIn : null,
|
|
41
42
|
selectedNetworkClientId: metamask.selectedNetworkClientId || null,
|
|
42
43
|
};
|
|
43
44
|
})()`;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { evalAsync, navigate } from '../platform/bridge.mjs';
|
|
2
|
+
|
|
3
|
+
const SWITCH_IDS = {
|
|
4
|
+
participate: 'metametrics-switch',
|
|
5
|
+
marketing: 'data-collection-switch',
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
function toggleExpression(testId, expected, invoke) {
|
|
9
|
+
return `(() => {
|
|
10
|
+
const find = () => {
|
|
11
|
+
const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
12
|
+
const rootsFor = hook?.getFiberRoots;
|
|
13
|
+
if (!hook?.renderers || typeof rootsFor !== 'function') return null;
|
|
14
|
+
const walk = (fiber) => {
|
|
15
|
+
if (!fiber) return null;
|
|
16
|
+
if (fiber.memoizedProps?.testID === ${JSON.stringify(testId)}) return fiber;
|
|
17
|
+
return walk(fiber.child) || walk(fiber.sibling);
|
|
18
|
+
};
|
|
19
|
+
for (const [id] of hook.renderers) {
|
|
20
|
+
for (const root of rootsFor(id) ?? []) {
|
|
21
|
+
const match = walk(root.current);
|
|
22
|
+
if (match) return match;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
};
|
|
27
|
+
const target = find();
|
|
28
|
+
const props = target?.memoizedProps;
|
|
29
|
+
if (!props) return { matched: false, found: false, invoked: false };
|
|
30
|
+
if (Boolean(props.value) === ${JSON.stringify(expected)}) {
|
|
31
|
+
return { matched: true, found: true, invoked: false };
|
|
32
|
+
}
|
|
33
|
+
if (!${JSON.stringify(invoke)}) {
|
|
34
|
+
return { matched: false, found: true, invoked: false };
|
|
35
|
+
}
|
|
36
|
+
if (typeof props.onValueChange !== 'function') {
|
|
37
|
+
throw new Error('Consent switch has no onValueChange handler: ${testId}');
|
|
38
|
+
}
|
|
39
|
+
return Promise.resolve(
|
|
40
|
+
props.onValueChange(${JSON.stringify(expected)})
|
|
41
|
+
).then(() => ({
|
|
42
|
+
matched: false,
|
|
43
|
+
found: true,
|
|
44
|
+
invoked: true
|
|
45
|
+
}));
|
|
46
|
+
})()`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stateExpression(participate, marketing) {
|
|
50
|
+
return `(() => {
|
|
51
|
+
const state = globalThis.store?.getState?.();
|
|
52
|
+
const analytics = state?.engine?.backgroundState?.AnalyticsController ?? {};
|
|
53
|
+
const consent = {
|
|
54
|
+
optedIn: Boolean(analytics.optedIn),
|
|
55
|
+
dataCollectionForMarketing: Boolean(state?.security?.dataCollectionForMarketing),
|
|
56
|
+
analyticsId: analytics.analyticsId ? 'set' : null
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
ready:
|
|
60
|
+
consent.optedIn === ${JSON.stringify(participate)} &&
|
|
61
|
+
consent.dataCollectionForMarketing === ${JSON.stringify(marketing)} &&
|
|
62
|
+
(!${JSON.stringify(participate)} || consent.analyticsId === 'set'),
|
|
63
|
+
consent
|
|
64
|
+
};
|
|
65
|
+
})()`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function delay(ms) {
|
|
69
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function setSwitch(input, testId, expected, timeoutMs) {
|
|
73
|
+
const deadline = Date.now() + timeoutMs;
|
|
74
|
+
let invoked = false;
|
|
75
|
+
while (Date.now() < deadline) {
|
|
76
|
+
const result = await evalAsync(
|
|
77
|
+
input,
|
|
78
|
+
toggleExpression(testId, expected, !invoked),
|
|
79
|
+
);
|
|
80
|
+
invoked ||= Boolean(result?.invoked);
|
|
81
|
+
if (result?.matched) {
|
|
82
|
+
return { ...result, changed: invoked };
|
|
83
|
+
}
|
|
84
|
+
await delay(100);
|
|
85
|
+
}
|
|
86
|
+
throw new Error(`Timed out setting consent switch ${testId} to ${expected}.`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function readConsent(input, participate, marketing, timeoutMs) {
|
|
90
|
+
const deadline = Date.now() + timeoutMs;
|
|
91
|
+
while (Date.now() < deadline) {
|
|
92
|
+
const result = await evalAsync(
|
|
93
|
+
input,
|
|
94
|
+
stateExpression(participate, marketing),
|
|
95
|
+
);
|
|
96
|
+
if (result?.ready) {
|
|
97
|
+
return result.consent;
|
|
98
|
+
}
|
|
99
|
+
await delay(100);
|
|
100
|
+
}
|
|
101
|
+
throw new Error('Timed out reading back Mobile analytics consent.');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function setMobileConsent(input, participate, marketing, timeoutMs) {
|
|
105
|
+
const navigation = await navigate(input, 'SettingsView', {
|
|
106
|
+
screen: 'SecuritySettings',
|
|
107
|
+
}, 'SecuritySettings');
|
|
108
|
+
await setSwitch(input, SWITCH_IDS.participate, participate, timeoutMs);
|
|
109
|
+
await setSwitch(input, SWITCH_IDS.marketing, marketing, timeoutMs);
|
|
110
|
+
const consent = await readConsent(input, participate, marketing, timeoutMs);
|
|
111
|
+
return { consent, navigation };
|
|
112
|
+
}
|
|
@@ -1,120 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { runAdapter } from '../platform/bridge.mjs';
|
|
2
2
|
import { consentParams } from '../../shared/analytics/consent.mjs';
|
|
3
|
-
|
|
4
|
-
const SWITCH_IDS = {
|
|
5
|
-
participate: 'metametrics-switch',
|
|
6
|
-
marketing: 'data-collection-switch',
|
|
7
|
-
};
|
|
8
|
-
|
|
9
|
-
function toggleExpression(testId, expected, invoke) {
|
|
10
|
-
return `(() => {
|
|
11
|
-
const find = () => {
|
|
12
|
-
const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
13
|
-
const rootsFor = hook?.getFiberRoots;
|
|
14
|
-
if (!hook?.renderers || typeof rootsFor !== 'function') return null;
|
|
15
|
-
const walk = (fiber) => {
|
|
16
|
-
if (!fiber) return null;
|
|
17
|
-
if (fiber.memoizedProps?.testID === ${JSON.stringify(testId)}) return fiber;
|
|
18
|
-
return walk(fiber.child) || walk(fiber.sibling);
|
|
19
|
-
};
|
|
20
|
-
for (const [id] of hook.renderers) {
|
|
21
|
-
for (const root of rootsFor(id) ?? []) {
|
|
22
|
-
const match = walk(root.current);
|
|
23
|
-
if (match) return match;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return null;
|
|
27
|
-
};
|
|
28
|
-
const target = find();
|
|
29
|
-
const props = target?.memoizedProps;
|
|
30
|
-
if (!props) return { matched: false, found: false, invoked: false };
|
|
31
|
-
if (Boolean(props.value) === ${JSON.stringify(expected)}) {
|
|
32
|
-
return { matched: true, found: true, invoked: false };
|
|
33
|
-
}
|
|
34
|
-
if (!${JSON.stringify(invoke)}) {
|
|
35
|
-
return { matched: false, found: true, invoked: false };
|
|
36
|
-
}
|
|
37
|
-
if (typeof props.onValueChange !== 'function') {
|
|
38
|
-
throw new Error('Consent switch has no onValueChange handler: ${testId}');
|
|
39
|
-
}
|
|
40
|
-
return Promise.resolve(
|
|
41
|
-
props.onValueChange(${JSON.stringify(expected)})
|
|
42
|
-
).then(() => ({
|
|
43
|
-
matched: false,
|
|
44
|
-
found: true,
|
|
45
|
-
invoked: true
|
|
46
|
-
}));
|
|
47
|
-
})()`;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function stateExpression(participate, marketing) {
|
|
51
|
-
return `(() => {
|
|
52
|
-
const state = globalThis.store?.getState?.();
|
|
53
|
-
const analytics = state?.engine?.backgroundState?.AnalyticsController ?? {};
|
|
54
|
-
const consent = {
|
|
55
|
-
optedIn: Boolean(analytics.optedIn),
|
|
56
|
-
dataCollectionForMarketing: Boolean(state?.security?.dataCollectionForMarketing),
|
|
57
|
-
analyticsId: analytics.analyticsId ? 'set' : null
|
|
58
|
-
};
|
|
59
|
-
return {
|
|
60
|
-
ready:
|
|
61
|
-
consent.optedIn === ${JSON.stringify(participate)} &&
|
|
62
|
-
consent.dataCollectionForMarketing === ${JSON.stringify(marketing)} &&
|
|
63
|
-
(!${JSON.stringify(participate)} || consent.analyticsId === 'set'),
|
|
64
|
-
consent
|
|
65
|
-
};
|
|
66
|
-
})()`;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function delay(ms) {
|
|
70
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function setSwitch(input, testId, expected, timeoutMs) {
|
|
74
|
-
const deadline = Date.now() + timeoutMs;
|
|
75
|
-
let invoked = false;
|
|
76
|
-
while (Date.now() < deadline) {
|
|
77
|
-
const result = await evalAsync(
|
|
78
|
-
input,
|
|
79
|
-
toggleExpression(testId, expected, !invoked),
|
|
80
|
-
);
|
|
81
|
-
invoked ||= Boolean(result?.invoked);
|
|
82
|
-
if (result?.matched) {
|
|
83
|
-
return { ...result, changed: invoked };
|
|
84
|
-
}
|
|
85
|
-
await delay(100);
|
|
86
|
-
}
|
|
87
|
-
throw new Error(`Timed out setting consent switch ${testId} to ${expected}.`);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
async function readConsent(input, participate, marketing, timeoutMs) {
|
|
91
|
-
const deadline = Date.now() + timeoutMs;
|
|
92
|
-
while (Date.now() < deadline) {
|
|
93
|
-
const result = await evalAsync(
|
|
94
|
-
input,
|
|
95
|
-
stateExpression(participate, marketing),
|
|
96
|
-
);
|
|
97
|
-
if (result?.ready) {
|
|
98
|
-
return result.consent;
|
|
99
|
-
}
|
|
100
|
-
await delay(100);
|
|
101
|
-
}
|
|
102
|
-
throw new Error('Timed out reading back Mobile analytics consent.');
|
|
103
|
-
}
|
|
3
|
+
import { setMobileConsent } from './consent-settings.mjs';
|
|
104
4
|
|
|
105
5
|
runAdapter(async (input) => {
|
|
106
6
|
const { participate, marketing, timeoutMs } = consentParams(input.node);
|
|
107
7
|
|
|
108
|
-
const navigation = await
|
|
109
|
-
|
|
110
|
-
}, 'SecuritySettings');
|
|
111
|
-
await setSwitch(input, SWITCH_IDS.participate, participate, timeoutMs);
|
|
112
|
-
await setSwitch(input, SWITCH_IDS.marketing, marketing, timeoutMs);
|
|
113
|
-
const consent = await readConsent(
|
|
114
|
-
input,
|
|
115
|
-
participate,
|
|
116
|
-
marketing,
|
|
117
|
-
timeoutMs,
|
|
8
|
+
const { consent, navigation } = await setMobileConsent(
|
|
9
|
+
input, participate, marketing, timeoutMs,
|
|
118
10
|
);
|
|
119
11
|
|
|
120
12
|
return {
|
|
@@ -454,6 +454,9 @@ export async function bridgeCommand(input, args) {
|
|
|
454
454
|
function redactBridgeArgs(args) {
|
|
455
455
|
const command = String(args[0] ?? '');
|
|
456
456
|
if (command === 'unlock' && args.length > 1) return [command, '<redacted-password>', ...args.slice(2)];
|
|
457
|
+
if (command === 'set-input' && isSensitiveInputId(args[1])) {
|
|
458
|
+
return [command, String(args[1]), '<redacted>'];
|
|
459
|
+
}
|
|
457
460
|
return args.map((arg) => String(arg));
|
|
458
461
|
}
|
|
459
462
|
|
|
@@ -470,9 +473,14 @@ function redactBridgeOutput(output, args) {
|
|
|
470
473
|
function sensitiveBridgeArgs(args) {
|
|
471
474
|
const command = String(args[0] ?? '');
|
|
472
475
|
if (command === 'unlock' && args.length > 1) return [args[1]];
|
|
476
|
+
if (command === 'set-input' && isSensitiveInputId(args[1])) return args.slice(2);
|
|
473
477
|
return [];
|
|
474
478
|
}
|
|
475
479
|
|
|
480
|
+
function isSensitiveInputId(value) {
|
|
481
|
+
return /(?:confirm-password|password|phrase|seed|srp)/iu.test(String(value ?? ''));
|
|
482
|
+
}
|
|
483
|
+
|
|
476
484
|
function parseMaybeJson(value) {
|
|
477
485
|
if (typeof value !== 'string') return value;
|
|
478
486
|
try {
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
|
+
import { createAgentDeviceClient } from 'agent-device';
|
|
3
4
|
|
|
4
5
|
import { bridgeCommand } from './bridge.mjs';
|
|
5
6
|
import { mobileToolRecovery, resolveMobileToolPath } from './tool-paths.mjs';
|
|
6
7
|
|
|
7
8
|
const execFileAsync = promisify(execFile);
|
|
8
|
-
const VISIBLE_LIMIT =
|
|
9
|
-
const HIDDEN_LIMIT =
|
|
9
|
+
const VISIBLE_LIMIT = 200;
|
|
10
|
+
const HIDDEN_LIMIT = 100;
|
|
10
11
|
|
|
11
12
|
export async function observeNativeUi(payload, context) {
|
|
12
13
|
await hideAccessibilityMaskingHud(payload, context);
|
|
@@ -111,6 +112,9 @@ async function readAndroidHierarchy(node, env) {
|
|
|
111
112
|
);
|
|
112
113
|
}
|
|
113
114
|
const serial = await resolveAndroidSerial(adbPath, node, env);
|
|
115
|
+
if (isOpaqueRuntime(env)) {
|
|
116
|
+
return readAndroidAgentDeviceHierarchy(serial, env);
|
|
117
|
+
}
|
|
114
118
|
const { stdout } = await execFileAsync(
|
|
115
119
|
adbPath,
|
|
116
120
|
['-s', serial, 'exec-out', 'uiautomator', 'dump', '/dev/tty'],
|
|
@@ -135,6 +139,84 @@ async function readAndroidHierarchy(node, env) {
|
|
|
135
139
|
);
|
|
136
140
|
}
|
|
137
141
|
|
|
142
|
+
async function readAndroidAgentDeviceHierarchy(serial, env) {
|
|
143
|
+
const session = `mm-harness-observe-${process.pid}-${Date.now()}`;
|
|
144
|
+
const client = createAgentDeviceClient({
|
|
145
|
+
session,
|
|
146
|
+
lockPolicy: 'reject',
|
|
147
|
+
lockPlatform: 'android',
|
|
148
|
+
});
|
|
149
|
+
try {
|
|
150
|
+
const snapshot = await client.capture.snapshot({
|
|
151
|
+
platform: 'android',
|
|
152
|
+
target: 'mobile',
|
|
153
|
+
serial,
|
|
154
|
+
session,
|
|
155
|
+
app: text(env.ANDROID_PACKAGE_ID) ?? 'io.metamask',
|
|
156
|
+
interactiveOnly: false,
|
|
157
|
+
forceFull: true,
|
|
158
|
+
});
|
|
159
|
+
const hierarchy = flattenAgentDeviceHierarchy(
|
|
160
|
+
snapshot.nodes,
|
|
161
|
+
text(env.ANDROID_PACKAGE_ID) ?? 'io.metamask',
|
|
162
|
+
);
|
|
163
|
+
if (hierarchy.nodeCount === 0) {
|
|
164
|
+
throw new Error('Agent Device returned an empty Android accessibility hierarchy.');
|
|
165
|
+
}
|
|
166
|
+
return normalizedHierarchy(
|
|
167
|
+
'agent-device-accessibility',
|
|
168
|
+
'android',
|
|
169
|
+
serial,
|
|
170
|
+
hierarchy.visible,
|
|
171
|
+
hierarchy.hidden,
|
|
172
|
+
hierarchy,
|
|
173
|
+
);
|
|
174
|
+
} finally {
|
|
175
|
+
await client.sessions.close({ session, shutdown: false });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function flattenAgentDeviceHierarchy(nodes, appId) {
|
|
180
|
+
const visible = [];
|
|
181
|
+
const hidden = [];
|
|
182
|
+
let screenName;
|
|
183
|
+
for (const source of Array.isArray(nodes) ? nodes : []) {
|
|
184
|
+
const node = record(source);
|
|
185
|
+
const bundleId = text(node.bundleId);
|
|
186
|
+
if (bundleId && bundleId !== appId) continue;
|
|
187
|
+
const bounds = normalizeBounds(node.rect ?? node.bounds);
|
|
188
|
+
const label = text(node.label) ?? text(node.value);
|
|
189
|
+
const role = text(node.type)?.toLowerCase();
|
|
190
|
+
const testId = text(node.identifier);
|
|
191
|
+
if (!isActionableRole(role) && !testId) continue;
|
|
192
|
+
screenName ??= label;
|
|
193
|
+
const item = compactItem({
|
|
194
|
+
role,
|
|
195
|
+
label,
|
|
196
|
+
test_id: testId,
|
|
197
|
+
enabled: boolean(node.enabled),
|
|
198
|
+
selected: boolean(node.selected),
|
|
199
|
+
focused: boolean(node.focused),
|
|
200
|
+
bounds,
|
|
201
|
+
});
|
|
202
|
+
if (hasVisibleBounds(bounds)) {
|
|
203
|
+
visible.push(item);
|
|
204
|
+
} else {
|
|
205
|
+
hidden.push({ ...item, reason: 'hidden_or_offscreen' });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
visible,
|
|
210
|
+
hidden,
|
|
211
|
+
nodeCount: Array.isArray(nodes) ? nodes.length : 0,
|
|
212
|
+
screenName,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function isOpaqueRuntime(env) {
|
|
217
|
+
return env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === '1';
|
|
218
|
+
}
|
|
219
|
+
|
|
138
220
|
function normalizedHierarchy(
|
|
139
221
|
provider,
|
|
140
222
|
platform,
|